Version 1.0.0
API reference
Donors, money and growth for small nonprofits, as an API. 247 operations. Base URL https://api.operationcharity.org. New here? Start with getting started.
How the API works
The one system a small nonprofit runs on. This spec is the product; the web app is its first client. If a screen can do it, the API can do it. Conventions are recorded in ADR-001 (docs/DECISIONS.md).
Authentication and scoping
Send an API key as Authorization: Bearer oc_live_… (server-to-server) or the web app's session cookie. Every request is scoped to the organization that owns the key or session. organization_id is never a request parameter and never appears in a path, query or body; it is derived from the credential and enforced by row-level security. The donation form page's two operations (/v1/public-donation-forms/{slug}, ADR-013) take no credential: the form's public address selects the organization, and they are rate-limited per client IP.
Two-step sign-in
A signed-in member whose organization requires two-step sign-in (require_two_step on the organization), or who has turned it on for themselves, must complete it (an authenticator app code) before the API answers their session. Until then every operation answers 403 with the problem type https://docs.operationcharity.org/problems/two-step-required. API keys are never affected (ADR-017).
Pagination
Every list returns { "data": [...], "next_cursor": "..." | null }. Pass cursor back to fetch the next page. limit is 1–200 (default 50). Cursors are opaque and expire after 24 hours. Each list documents its stable sort.
Errors
Errors are RFC 9457 problem details (application/problem+json). Validation errors carry errors[] with a JSON Pointer into the request body.
Money and dates
Every amount is integer cents in a *_cents field. Dates are YYYY-MM-DD in the organization's timezone; timestamps are RFC 3339 in UTC.
Idempotency
POSTs that create money records (gifts, gift adjustments, receipts, import commits) require an Idempotency-Key header. Replaying a key with the same body returns the original response; a different body returns 409.
Rate limits
Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Over the limit you get 429 with Retry-After. Default: 600 requests per minute per API key.
Deleting and restoring
Every destructive action is undoable (ADR-005). DELETE on a data resource is a soft delete: it sets deleted_at, hides the record from lists (include_deleted=true, admin only, shows them) and keeps it readable by id. POST /v1/<resource>/{id}/restore clears deleted_at. Gifts, gift adjustments and receipts are append-only and never deleted; API keys are revoked; users are deactivated and reactivated rather than deleted.
Versioning
The URL prefix (/v1) is the version. Additive changes (new fields, new endpoints, new enum values on responses) are non-breaking. Breaking changes ship under /v2 with an ADR.
Health
Check that the API is up
GET /v1/healthNo key needed
Responses
200The API is serving requests.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"status": "ok",
"version": "1.0.0",
"time": "2026-10-05T14:30:00Z"
}Organizations
Get the current organization
GET /v1/organization
The organization that owns the API key or session. There is no way to read any other organization.
Responses
200The organization.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d01",
"name": "Maple Street Food Bank",
"ein": "84-1234567",
"timezone": "America/Denver",
"fiscal_year_start_month": 7,
"mailing_address": "1200 Maple Street\nDenver, CO 80202",
"receipt_signature_name": "Rosa Diaz",
"receipt_signature_title": "Executive director",
"receipt_footer": "Questions about your gift? Call 303-555-0100.",
"intangible_religious_benefits": false,
"receipt_logo_url": null,
"require_two_step": false,
"created_at": "2026-01-15T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}Update the current organization
PATCH /v1/organization
Admin role or a write-scoped key. Changing fiscal_year_start_month changes every fiscal-year report; the change is audited. require_two_step can be changed only by a signed-in admin whose session has completed two-step sign-in (aal2): an API key of any scope, and an admin who has not set up an authenticator app, get 403 for it (ADR-017). Once on, every member's session must complete two-step sign-in before the API answers it. Emits organization.updated.
Request body (OrganizationUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | 1–120 characters (spaces at either end are dropped), without quotes, angle brackets, control characters, or invisible direction or formatting characters (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069, U+FEFF): 422. The invitation email names the organization (ADR-017). |
ein | string or null | No | |
timezone | string | No | |
fiscal_year_start_month | integer | No | |
mailing_address | string or null | No | |
receipt_signature_name | string or null | No | |
receipt_signature_title | string or null | No | |
receipt_footer | string or null | No | |
intangible_religious_benefits | boolean | No | |
receipt_logo_url | string or null | No | Must start with https://. |
require_two_step | boolean | No | Only a signed-in admin whose session has completed two-step sign-in ( |
drafter_cannot_approve | boolean | No | Only a signed-in admin; an API key gets 403 for this field (ADR-023). |
Example request
{
"name": "Maple Street Food Bank",
"fiscal_year_start_month": 7
}Responses
200The updated organization.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d01",
"name": "Maple Street Food Bank",
"ein": "84-1234567",
"timezone": "America/Denver",
"fiscal_year_start_month": 7,
"mailing_address": "1200 Maple Street\nDenver, CO 80202",
"receipt_signature_name": "Rosa Diaz",
"receipt_signature_title": "Executive director",
"receipt_footer": "Questions about your gift? Call 303-555-0100.",
"intangible_religious_benefits": false,
"receipt_logo_url": null,
"require_two_step": false,
"created_at": "2026-01-15T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}Users
List users
GET /v1/users
Sorted by full_name, then id. Deactivated users are included (deactivated_at set) so admins can reactivate them. A person who has not signed in yet carries their newest invitation's status and sent time in invitation (ADR-017).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
role | one of: admin, staff, read_only | No | |
q | string | No | Matches name or email. |
Responses
200A page of users.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"email": "amara@maplestreetfoodbank.org",
"full_name": "Amara Okafor",
"role": "staff",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": {
"status": "queued",
"queued_at": "2026-10-12T15:00:00Z",
"sent_at": null,
"attempts": 0,
"last_error": null,
"sending_configured": true,
"sign_in_url": "https://app.maplestreetfoodbank.org/sign-in"
}
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"email": "dana@maplestreetfoodbank.org",
"full_name": "Dana Whitfield",
"role": "admin",
"created_at": "2026-01-15T16:00:00Z",
"updated_at": "2026-01-15T16:00:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": null
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"email": "luis@maplestreetfoodbank.org",
"full_name": "Luis Ortega",
"role": "staff",
"created_at": "2026-02-02T18:12:00Z",
"updated_at": "2026-02-02T18:12:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": null
}
],
"next_cursor": null
}Get the caller
GET /v1/users/me
Who is calling (B1.1). A signed-in user gets their user record, with the role, and their organization; an API key gets its organization and scopes. Any credential may call it, a read-only one included.
Responses
200The caller.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"kind": "session",
"organization": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d01",
"name": "Maple Street Food Bank",
"ein": "84-1234567",
"timezone": "America/Denver",
"fiscal_year_start_month": 7,
"mailing_address": "1200 Maple Street\nDenver, CO 80202",
"receipt_signature_name": "Rosa Diaz",
"receipt_signature_title": "Executive director",
"receipt_footer": "Questions about your gift? Call 303-555-0100.",
"intangible_religious_benefits": false,
"receipt_logo_url": null,
"require_two_step": false,
"created_at": "2026-01-15T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z"
},
"access": "admin",
"user": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"email": "dana@maplestreetfoodbank.org",
"full_name": "Dana Whitfield",
"role": "admin",
"created_at": "2026-01-15T16:00:00Z",
"updated_at": "2026-01-15T16:00:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": null
},
"api_key": null
}Invite a user
POST /v1/users/invite
Admin only. Creates the user with the given role, waiting for the person to sign in, and queues their invitation (ADR-017); the response's invitation says it is queued. Once the request has committed, the invitation drain has the platform's invite-member function create the person's login, and Supabase emails them a link to this deployment's confirm page, naming this organization; GET /v1/users then shows how it went (invitation.status), and their first sign-in links them to this user. Without the function set up on this deployment (invitation.sending_configured: false) the invitation waits in the queue and nothing is emailed. Nothing else creates a login: the sign-in page never does. 409 when the address already belongs to a user here, or to an active member of another organization ("This person already uses Operation Charity with another organization. One sign-in can belong to only one organization for now."; the answer never says which). When the address has a pending invitation from another organization, nothing is sent and the invitation ends pending_elsewhere (again never saying which). 429, with Retry-After, beyond 10 invitations an hour for the organization, first sends, resends and refusals together. The invitation is audited. Emits user.created.
Request body (UserInvite)
| Name | Type | Required | Description |
|---|---|---|---|
email | string (email) | Yes | |
full_name | string | Yes | |
role | one of: admin, staff, read_only | No |
Example request
{
"email": "amara@maplestreetfoodbank.org",
"full_name": "Amara Okafor",
"role": "staff"
}Responses
201The invited user, with the invitation queued.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"email": "amara@maplestreetfoodbank.org",
"full_name": "Amara Okafor",
"role": "staff",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": {
"status": "queued",
"queued_at": "2026-10-12T15:00:00Z",
"sent_at": null,
"attempts": 0,
"last_error": null,
"sending_configured": true,
"sign_in_url": "https://app.maplestreetfoodbank.org/sign-in"
}
}Get a user
GET /v1/users/{id}
Deactivated users are returned with deactivated_at set; a person who has not signed in yet, with their newest invitation in invitation.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The user.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"email": "luis@maplestreetfoodbank.org",
"full_name": "Luis Ortega",
"role": "staff",
"created_at": "2026-02-02T18:12:00Z",
"updated_at": "2026-02-02T18:12:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": null
}Change a user's role
PATCH /v1/users/{id}
Admin only. The last admin cannot be demoted (409). Emits user.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (UserRoleChange)
| Name | Type | Required | Description |
|---|---|---|---|
role | one of: admin, staff, read_only | Yes |
Example request
{
"role": "admin"
}Responses
200The updated user.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"email": "luis@maplestreetfoodbank.org",
"full_name": "Luis Ortega",
"role": "admin",
"created_at": "2026-02-02T18:12:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": null
}Deactivate a user
POST /v1/users/{id}/deactivate
Admin only. Sets deactivated_at (the row is kept, ADR-005): the user can no longer sign in or use the API. Their past activity stays in the audit log. 409 if the user is already deactivated or is the last active admin. Undo with POST /v1/users/{id}/reactivate. Emits user.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The deactivated user.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"email": "luis@maplestreetfoodbank.org",
"full_name": "Luis Ortega",
"role": "staff",
"created_at": "2026-02-02T18:12:00Z",
"updated_at": "2026-10-05T16:00:00Z",
"deactivated_at": "2026-10-05T16:00:00Z",
"deactivated_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"invitation": null
}Reactivate a user
POST /v1/users/{id}/reactivate
Admin only. Clears deactivated_at and deactivated_by; the user keeps their previous role. 409 if the user is not deactivated. Emits user.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The reactivated user.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"email": "luis@maplestreetfoodbank.org",
"full_name": "Luis Ortega",
"role": "staff",
"created_at": "2026-02-02T18:12:00Z",
"updated_at": "2026-02-02T18:12:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": null
}Send an invitation again
POST /v1/users/{id}/resend-invitation
Admin only (a signed-in admin; API keys get 403). Queues a new invitation for a person who has not signed in yet, for example when the first one expired, failed or was never sent, or ended pending_elsewhere and that other invitation has since been accepted or withdrawn (ADR-017); their older queued invitations are superseded, since only the newest is ever sent. The drain sends it after the request commits, to the address on their user; the link in an earlier invitation email then stops working. 409 when they have already signed in or the user is deactivated. 429, with Retry-After, beyond 10 invitations an hour for the organization, first sends, resends and refusals together. Without the invite function set up on this deployment the invitation waits in the queue (invitation.sending_configured: false). Audited. Emits user.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
202The user, with the new invitation queued.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (202)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"email": "amara@maplestreetfoodbank.org",
"full_name": "Amara Okafor",
"role": "staff",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z",
"deactivated_at": null,
"deactivated_by": null,
"invitation": {
"status": "queued",
"queued_at": "2026-10-14T09:30:00Z",
"sent_at": null,
"attempts": 0,
"last_error": null,
"sending_configured": true,
"sign_in_url": "https://app.maplestreetfoodbank.org/sign-in"
}
}Contacts
List contacts
GET /v1/contacts
Sorted by last_name, first_name (org_name for organizations), then id. Soft-deleted records are excluded unless include_deleted=true. With include=giving every contact on the page carries giving (ContactGiving, ADR-016), worked out for the contacts on this page only. Every contact carries its tags; tag keeps contacts with any of the given tags (ADR-018). saved_list_id keeps a saved list's members now and snapshot_id a snapshot's (ADR-020), and include=total adds how many the whole query holds. POST /v1/contacts/export gives the same list as a CSV file, with the same filters in its body. Unknown query parameters are a 400 (ADR-020).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
q | string | No | Matches name, email or phone. |
kind | one of: person, organization | No | |
household_id | string (uuid) | No | |
email | string | No | Exact match, case-insensitive. |
do_not_contact | boolean | No | |
updated_since | string (date-time) | No | Only records updated at or after this timestamp (UTC). |
ids | list of string (uuid) | No | Only these contacts (B1.1): a comma-separated list of up to 200 ids, for example the contact_ids of a page of gifts. Combines with the other filters; ids that match nothing are skipped. 400 for more than 200 ids or one that is not a UUID. |
tag | list of string (uuid) | No | Only contacts with any of these tags (ADR-018): a comma-separated list of 1 to 50 tag ids. A deleted tag, and an id that names no tag, matches nobody. 400 for more than 50 ids or one that is not a UUID. |
saved_list_id | string (uuid) | No | Only the members of this saved list now (ADR-020): its filter, worked out as of this request, ANDed with the other filters. 409 while the list needs fixing or is deleted. A list with a giving condition draws on the organization's evaluation bucket (429) and answers within 10 seconds (503). Never with |
snapshot_id | string (uuid) | No | Only the contacts this snapshot froze (ADR-020), ANDed with the other filters. Members deleted since are left out unless |
include | list of one of: giving, total | No | A comma-separated list of |
Responses
200A page of contacts.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409The saved list can't be worked out now (ADR-020): a condition names a tag, fund, campaign or appeal deleted since, a custom field whose type no longer allows it, or the stored filter no longer passes the grammar; or the list is deleted. Type `https://docs.operationcharity.org/problems/saved-list-needs-fixing`, with a pointer and a message per condition in `errors`. Restore the record or edit the list. Nothing was changed.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Working out a list's giving conditions took longer than 10 seconds, the gift load and the money library included (ADR-020). Nothing was changed; try again, or narrow the list. Lists are built for organizations of up to 20,000 contacts and 100,000 gifts.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"kind": "organization",
"first_name": null,
"last_name": null,
"org_name": "Cedar Ridge Community Foundation",
"display_name": "Cedar Ridge Community Foundation",
"email": "grants@cedarridgecf.org",
"phone": null,
"household_id": null,
"address_line1": "200 Grant St",
"address_line2": "Suite 400",
"city": "Denver",
"region": "CO",
"postal_code": "80203",
"country": "US",
"employer": null,
"do_not_contact": false,
"email_opt_in": false,
"sms_opt_in": false,
"source": "manual",
"source_ref": null,
"consented": true,
"custom_fields": {},
"tags": [],
"created_at": "2026-02-10T17:00:00Z",
"updated_at": "2026-02-10T17:00:00Z",
"deleted_at": null,
"deleted_by": null
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": "eyJsYXN0X25hbWUiOiJOYWthbXVyYSIsImlkIjoiLi4uIn0"
}Create a contact
POST /v1/contacts
Emits contact.created. Does not dedupe; use the importer or GET /v1/contacts?email= to find existing records first. Every defined key in custom_fields is checked against its custom field definition (ADR-018): a value of the wrong type is a 422 pointing at /custom_fields/<key>; keys with no definition are stored as sent. Tags are added afterwards (PATCH /v1/contacts/{id}/tags).
Request body (ContactCreate)
| Name | Type | Required | Description |
|---|---|---|---|
address_line1 | string or null | No | |
address_line2 | string or null | No | |
city | string or null | No | |
region | string or null | No | State or province. |
postal_code | string or null | No | |
country | string or null | No | |
kind | one of: person, organization | No | |
first_name | string or null | No | |
last_name | string or null | No | |
org_name | string or null | No | Required when kind is organization. |
email | string (email) or null | No | |
phone | string or null | No | |
household_id | string (uuid) or null | No | |
employer | string or null | No | |
do_not_contact | boolean | No | Setting it is recorded as a consent event (ADR-024). Clearing it needs a signed-in user and |
email_opt_in | boolean | No | Create and update only tighten (ADR-024): |
email_appeals_opt_out | boolean | No | The person opted out of appeals (ADR-023). Can be set here; clearing it is 422 (only the person opts back in). |
email_newsletters_opt_out | boolean | No | The person opted out of newsletters (ADR-023). Can be set here; clearing it is 422. |
do_not_contact_reason | string | No | Update only: why do-not-contact is being cleared (required to clear it); kept in the consent log. |
sms_opt_in | boolean | No | |
source | string or null | No | Where the record came from (import, form, referral, public-record). |
source_ref | string or null | No | Import job id, form id or URL. |
consented | boolean | No | Engine gate. Contacts found in public records stay false until they opt in. |
custom_fields | object | No | Values of the organization's custom fields, by key (ADR-018). The object replaces the stored one, as before, so a client changing one field sends every key. A write is therefore checked only for the defined keys ( |
Example request
{
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"employer": "Front Range Dental",
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
}
}Responses
201The new contact.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Get a contact
GET /v1/contacts/{id}
A soft-deleted contact is still returned, with deleted_at set, so undo screens can show it. Lists hide it. With include=giving the contact carries giving (ContactGiving, ADR-016); with include=email_consent, email_consent (EmailConsentState, F1); include=giving,email_consent both.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
include | list of one of: giving, email_consent | No | A comma-separated list of |
Responses
200The contact.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Update a contact
PATCH /v1/contacts/{id}
Partial update; omitted fields are unchanged, null clears. custom_fields replaces the stored object, so send every key to keep; each defined key whose value it adds or changes is checked against its custom field definition (ADR-018, 422 pointing at /custom_fields/<key>), a value equal to the stored one passes as it is, and keys with no definition are stored as sent. Emits contact.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (ContactUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
address_line1 | string or null | No | |
address_line2 | string or null | No | |
city | string or null | No | |
region | string or null | No | State or province. |
postal_code | string or null | No | |
country | string or null | No | |
kind | one of: person, organization | No | |
first_name | string or null | No | |
last_name | string or null | No | |
org_name | string or null | No | Required when kind is organization. |
email | string (email) or null | No | |
phone | string or null | No | |
household_id | string (uuid) or null | No | |
employer | string or null | No | |
do_not_contact | boolean | No | Setting it is recorded as a consent event (ADR-024). Clearing it needs a signed-in user and |
email_opt_in | boolean | No | Create and update only tighten (ADR-024): |
email_appeals_opt_out | boolean | No | The person opted out of appeals (ADR-023). Can be set here; clearing it is 422 (only the person opts back in). |
email_newsletters_opt_out | boolean | No | The person opted out of newsletters (ADR-023). Can be set here; clearing it is 422. |
do_not_contact_reason | string | No | Update only: why do-not-contact is being cleared (required to clear it); kept in the consent log. |
sms_opt_in | boolean | No | |
source | string or null | No | Where the record came from (import, form, referral, public-record). |
source_ref | string or null | No | Import job id, form id or URL. |
consented | boolean | No | Engine gate. Contacts found in public records stay false until they opt in. |
custom_fields | object | No | Values of the organization's custom fields, by key (ADR-018). The object replaces the stored one, as before, so a client changing one field sends every key. A write is therefore checked only for the defined keys ( |
Example request
{
"email_opt_in": false,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
}
}Responses
200The updated contact.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a contact
DELETE /v1/contacts/{id}
Soft delete (ADR-005): sets deleted_at and hides the contact from lists; POST /v1/contacts/{id}/restore undoes it. Still refused (409) while the contact has gifts, receipts or soft credits, so reports stay intact; merge or keep the record instead. Also refused (409, ADR-013) while the contact has a recurring donation that has not ended, or while a donation form payment from the contact's email address is in progress (an open checkout), so an online payment never arrives for a deleted donor. Communications, tasks and relationships are soft-deleted with it and restored with it. Emits contact.deleted with the row (deleted_at set) so the web app can offer undo.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted contact
POST /v1/contacts/{id}/restore
Clears deleted_at and deleted_by on the contact and on the communications and tasks deleted with it. A relationship deleted with it comes back only when its other contact is live; while the other contact is still deleted, the relationship takes that contact's deletion and comes back with it (ADR-018). Children deleted on their own earlier stay deleted. 409 if the contact is not deleted. Emits contact.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored contact, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Get a contact's timeline
GET /v1/contacts/{id}/timeline
Everything about one contact in one list (B1.1): the gifts they gave (with receipt_required and display names), adjustments to those gifts (each with the gift it adjusts), soft credits they received (each with the credited gift), receipts and year-end statements issued to them, communications and tasks. Each item is discriminated by kind. Sorted by occurred_at descending, then kind in the order gift, adjustment, soft_credit, receipt, communication, task, then id. Dated entries (gifts, adjustments, soft credits, open tasks with a due date) sort at the start of their day in the organization's timezone. Soft-deleted soft credits, communications and tasks are excluded unless include_deleted=true (admin only). A deleted contact still has a timeline, like GET by id. kind narrows the list to some kinds, for example kind=gift,adjustment for giving history.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
kind | list of one of: gift, adjustment, soft_credit, receipt, communication, task | No | Only these kinds: a comma-separated list of gift, adjustment, soft_credit, receipt, communication, task. Omit for all. 400 for an unknown kind. |
Responses
200A page of the contact's timeline, newest first.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"kind": "task",
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"occurred_at": "2026-10-06T06:00:00Z",
"occurred_on": "2026-10-06",
"task": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "open",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": null,
"completed_at": null,
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z",
"deleted_at": null,
"deleted_by": null
}
},
{
"kind": "communication",
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"occurred_at": "2026-10-05T15:00:00Z",
"occurred_on": null,
"communication": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "email",
"direction": "outbound",
"subject": "Thank you for keeping the shelter warm",
"body_text": "Aiko, your gift of $50.00 to the Winter Shelter Fund means two more nights of warm beds. Thank you.",
"body_html": null,
"message_id": "<thanks-6d13@maplestreetfoodbank.org>",
"thread_id": null,
"from_address": "luis@maplestreetfoodbank.org",
"to_addresses": [
"aiko.nakamura@example.com"
],
"occurred_at": "2026-10-05T15:00:00Z",
"source": "manual",
"logged_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"inbound_email_id": null,
"email_message_id": null,
"attachment_count": null,
"created_at": "2026-10-05T15:00:00Z",
"updated_at": "2026-10-05T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}
},
{
"kind": "adjustment",
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"occurred_at": "2026-10-05T06:00:00Z",
"occurred_on": "2026-10-05",
"adjustment": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"kind": "reallocate",
"amount_cents": 5000,
"from_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"to_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"effective_on": "2026-10-05",
"reason": "Donor asked that the gift be unrestricted.",
"exception_reason": null,
"consent_reference": "Donor email of 2026-10-03, filed in the donor record",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:30:00Z"
},
"gift": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"campaign_name": "Winter 2026",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"appeal_name": "September letter",
"grant_id": null,
"kind": "one_time",
"status": "posted",
"amount_cents": 5000,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-10-04",
"contribution_date": "2026-10-04",
"payment_method": "card",
"external_ref": "pi_3Q8kLm2eZvKYlo2C1aBcDeFg",
"in_kind_description": null,
"property_description": null,
"book_fair_value_cents": null,
"valuation_method": null,
"nonfinancial_category": null,
"pledge_id": null,
"tribute_type": null,
"tribute_name": null,
"notify_contact_id": null,
"notes": "Gave through the website after the September letter.",
"receipt_required": false,
"donation_form_id": null,
"donation_form_title": null,
"recurring_donation_id": null,
"recurring_interval": null,
"batch_id": null,
"batch_name": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z"
}
},
{
"kind": "receipt",
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"occurred_at": "2026-10-04T22:20:00Z",
"occurred_on": null,
"receipt": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "gift",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"tax_year": null,
"contribution_date": "2026-10-04",
"total_cents": 5000,
"deductible_cents": 5000,
"goods_services_statement": "No goods or services were provided in exchange for this contribution.",
"rendered_text": "Maple Street Food Bank\n1200 Maple Street\nDenver, CO 80202\nEIN 84-1234567\n\nContribution receipt\n\nReceipt number: 3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16\nDate issued: October 4, 2026\n\nDonor\nAiko Nakamura\n\nContribution\nContribution date: October 4, 2026\nAmount: $50.00\nDesignated to: General fund (unrestricted)\nDeductible amount: $50.00\n\nNo goods or services were provided in exchange for this contribution.\n\nThis receipt is your written acknowledgment of this contribution for federal income tax purposes. Please keep it with your tax records.\n",
"supersedes_receipt_id": null,
"superseded_at": null,
"reissue_reason": null,
"sent_at": "2026-10-04T22:20:00Z",
"sent_via": "email",
"created_at": "2026-10-04T22:20:00Z",
"updated_at": "2026-10-04T22:20:00Z"
}
},
{
"kind": "gift",
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"occurred_at": "2026-10-04T06:00:00Z",
"occurred_on": "2026-10-04",
"gift": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"campaign_name": "Winter 2026",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"appeal_name": "September letter",
"grant_id": null,
"kind": "one_time",
"status": "posted",
"amount_cents": 5000,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-10-04",
"contribution_date": "2026-10-04",
"payment_method": "card",
"external_ref": "pi_3Q8kLm2eZvKYlo2C1aBcDeFg",
"in_kind_description": null,
"property_description": null,
"book_fair_value_cents": null,
"valuation_method": null,
"nonfinancial_category": null,
"pledge_id": null,
"tribute_type": null,
"tribute_name": null,
"notify_contact_id": null,
"notes": "Gave through the website after the September letter.",
"receipt_required": false,
"donation_form_id": null,
"donation_form_title": null,
"recurring_donation_id": null,
"recurring_interval": null,
"batch_id": null,
"batch_name": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z"
}
}
],
"next_cursor": null
}Add or remove tags on a contact
PATCH /v1/contacts/{id}/tags
Staff and write keys (ADR-018). Adds the tags in add and takes off those in remove, in one change. Each id must be a live tag of the organization (422 with a pointer to the id). A tag that comes off is kept on record and the same assignment comes back when it is added again, so undoing a removal is adding the tag back. A deleted contact cannot be tagged (409; restore it first). When the contact's tags changed, its updated_at moves, the change is in the audit log with who made it, and contact.updated is emitted with the new tags; a request that changes nothing emits nothing. Answers the contact as GET does (without giving).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (ContactTagsUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
add | list of string (uuid) | No | Live tags of the organization. |
remove | list of string (uuid) | No | Live tags of the organization. |
Example request
{
"add": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01"
]
}Responses
200The contact with its tags.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Tag, untag, delete or restore many contacts
POST /v1/contacts/bulkIdempotency-Key required
Staff and write keys (ADR-018). One action on up to 500 contacts: add_tag and remove_tag (with tag_id, a live tag), delete and restore. Not all-or-nothing: it answers 200 with each contact's outcome, in the order sent - changed, unchanged, or refused with the reason. Delete applies the rules of DELETE /v1/contacts/{id} to each contact (ADR-005: a contact with gifts, receipts or soft credits, a recurring donation that has not ended or a payment in progress is refused) and the others are deleted; restore those of POST /v1/contacts/{id}/restore. A deleted contact is refused for add_tag and remove_tag, and an id with no contact in this organization is refused for every action. A contact whose custom fields are still the text an old import stored is refused for every action, with the reason, until its custom fields are saved again (the database refuses any write to it). Undo is the opposite action on exactly the changed ids (remove_tag for add_tag and back, restore for delete). A delete stamps one time on every contact it deletes and on their communications, tasks and relationships, as a single delete would; a restore brings back each contact's own communications and tasks, and a relationship only once its other contact is live too (until then it waits for that contact's restore), exactly as POST /v1/contacts/{id}/restore does (ADR-018). Requires Idempotency-Key: a retry with the same key and body answers the first result and changes nothing more. Every change is written to the audit log per contact, with who made it, and emits that contact's own event as one at a time would: contact.updated for a tag added or removed and for a restore, contact.deleted for a delete. Unchanged and refused contacts emit nothing. 422 for more than 500 ids, a repeated id, a missing, deleted or unknown tag, or a tag_id with delete or restore.
Over a snapshot (ADR-020): send snapshot_id instead of contact_ids to start a bulk run that adds or removes a tag on the snapshot's members, or deletes them, exactly the frozen members (not a list its own changes move). The first call records the run with its action and tag; every later call sends the next_cursor it was given and follows the run's stored action and tag, never its body's. Each call takes the next 500 members by contact id, applies the rules above, records the contacts it changed with its time in one statement, and answers bulk_run_id and next_cursor (null once every member is taken: the run is completed); each call has its own Idempotency-Key. A cursor is taken only from whoever started the run, only while it is running (409 once it is being undone or undone), and lasts 24 hours; GET /v1/bulk-runs/{id} gives a fresh one. A delete run needs a signed-in admin on every call (403 for staff and API keys, who keep the delete of up to 500 hand-picked contacts); tagging runs take write access. A run's tag must still be live on every call: once it is deleted the run answers 409 until the tag is restored. A cursor naming no run of this organization is a 404. Each call takes the organization's evaluation lock and draws on its bucket (ADR-020, Cost): 429 with Retry-After while another evaluation holds the lock or the bucket is spent, so a run of more than 30 calls waits and continues. POST /v1/bulk-runs/{id}/undo reverses the run from its history.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (ContactBulkRequest)
| Name | Type | Required | Description |
|---|---|---|---|
action | one of: add_tag, remove_tag, delete, restore | Yes | What to do to each contact. The undo of each is another: remove_tag undoes add_tag and the other way round, restore undoes delete. |
contact_ids | list of string (uuid) | No | Up to 500 contacts of the organization. Ids are read in any case and answered in lower case, |
tag_id | string (uuid) | No | The tag, for add_tag and remove_tag (a live tag of the organization); refused with delete and restore. |
snapshot_id | string (uuid) | No | Starts a bulk run over the snapshot's members (ADR-020): add_tag, remove_tag or delete (restore stays ids-only). Delete needs a signed-in admin. The call takes the members' first 500 by contact id. |
cursor | string | No | Continues a run: the |
Example request
{
"action": "add_tag",
"tag_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"contact_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d81"
]
}Responses
200Each contact's outcome and the counts.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"action": "add_tag",
"tag_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"results": [
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"outcome": "unchanged",
"reason": null
},
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d81",
"outcome": "changed",
"reason": null
}
],
"counts": {
"changed": 1,
"unchanged": 1,
"refused": 0
},
"bulk_run_id": null,
"next_cursor": null
}Export contacts as CSV
POST /v1/contacts/export
Admins, staff and write-scoped keys (ADR-018); read-only members and read keys get 403. Takes up to 500 ids or the list's filters in its body and answers one CSV file with exactly the rows GET /v1/contacts gives this caller for the same filters (include_deleted admin only, as on the list), in the same order, unpaginated, and a column for each field it gives: Contact ID, Kind (Person or Organization), Name, First name, Last name, Organization name, Email, Phone, Household ID, Address line 1, Address line 2, City, State or province, Postal code, Country, Employer, Do not contact, Email opt-in, Opted out of appeals, Opted out of newsletters, Text opt-in, Source, Source reference, Consented (yes or no for each flag), Tags (their names, separated by "; "), Created at, Updated at, Deleted at, Deleted by (timestamps as the API writes them); then include=giving's summary (ADR-016), the same numbers: Lifetime giving (USD), Gifts, First gift date, Last gift date, Last gift ID, Last gift (USD), Last gift kind (One-time, Recurring, Pledge payment, In-kind or Grant payment), Largest gift (USD), empty where giving has null; then custom_fields: a column for every custom field in use, in their order and headed by the label, then one for each archived field or key with no definition that any row holds, headed by its label (archived) or its key; yes or no for true and false, JSON for anything else that is not text or a number. A header that would repeat one before it gets its key in brackets. Text cells (headers included) that start with =, +, -, @, a tab or a carriage return get a leading apostrophe, and so does each part of a cell after a semicolon or a tab (the list separator in some locales, where a spreadsheet splits lines there) or after a line break (where that spreadsheet also ends the row: a cell's own quotes keep a line break inside it there only when the cell starts the line) that starts with =, +, - or @, after any spaces or quotes; a cell holding a semicolon, a tab or a line break is quoted. So a spreadsheet does not run them as formulas (C5). One file holds at most 10,000 contacts and 4,000,000 bytes, whichever comes first, under the hosting's 4.5 MB response limit; past either it is a 422 of type https://docs.operationcharity.org/problems/export-too-large that says how many matched and how to narrow it (a tag, a kind, a search, fewer ids), and never a partial file. With saved_list_id or snapshot_id (ADR-020) it answers a saved list's or a snapshot's members from one evaluation, exactly the rows and columns GET /v1/contacts gives for the same parameters; a list that needs fixing is a 409, and a list with a giving condition draws on the organization's evaluation bucket (429) and answers within 10 seconds (503). The audit log then also keeps the list or snapshot and the filter as evaluated. One export also reads at most 32 MB of the contacts' stored custom fields (their JSON, keys included), with the same 422 past that. Rate-limited to 30 requests per minute per caller, in its own bucket. Every export is written to the audit log (action export, table_name contacts, row_id the export's id), because the file carries personal data: who (the user, or the API key's id), when, the ids of the contacts exported and how many ids were asked for (requested_count), or the filters, how many columns and which of the organization's custom-field keys they include, and how many rows. The record copies nobody's details: a search or an email filter is kept by its length only, and keys no field defines are counted, not named.
Request body (ContactExportRequest)
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes |
|
ids | list of string (uuid) | No | Only these contacts, in the list's order; ids that match nothing are skipped. |
include_deleted | boolean | No | Admin only, as on the list: include soft-deleted contacts. |
q | string | No | Matches name, email or phone. The audit log keeps its length, never its text. |
kind | one of: person, organization | No | |
household_id | string (uuid) | No | |
email | string | No | Exact match, case-insensitive. The audit log keeps its length, never the address. |
do_not_contact | boolean | No | |
updated_since | string (date-time) | No | |
tag | list of string (uuid) | No | Contacts with any of these tags. |
saved_list_id | string (uuid) | No | A saved list's members now (ADR-020), as |
snapshot_id | string (uuid) | No | A snapshot's members (ADR-020), deleted ones left out unless |
Example request
{
"format": "csv",
"ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d81"
]
}Responses
200The contacts.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409The saved list can't be worked out now (ADR-020): a condition names a tag, fund, campaign or appeal deleted since, a custom field whose type no longer allows it, or the stored filter no longer passes the grammar; or the list is deleted. Type `https://docs.operationcharity.org/problems/saved-list-needs-fixing`, with a pointer and a message per condition in `errors`. Restore the record or edit the list. Nothing was changed.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Working out a list's giving conditions took longer than 10 seconds, the gift load and the money library included (ADR-020). Nothing was changed; try again, or narrow the list. Lists are built for organizations of up to 20,000 contacts and 100,000 gifts.
Example response (200)
"Contact ID,Kind,Name,First name,Last name,Organization name,Email,Phone,Household ID,Address line 1,Address line 2,City,State or province,Postal code,Country,Employer,Do not contact,Email opt-in,Opted out of appeals,Opted out of newsletters,Text opt-in,Source,Source reference,Consented,Tags,Created at,Updated at,Deleted at,Deleted by,Lifetime giving (USD),Gifts,First gift date,Last gift date,Last gift ID,Last gift (USD),Last gift kind,Largest gift (USD),T-shirt size,Volunteers with us\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05,Person,Aiko Nakamura,Aiko,Nakamura,,aiko.nakamura@example.com,'+1 303 555 0142,3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04,1420 Spruce St,,Denver,CO,80205,US,Front Range Dental,no,yes,no,no,no,form,donation-form,yes,Volunteer,2026-03-01T15:00:00+00:00,2026-10-05T14:30:00+00:00,,,75.00,2,2026-10-04,2026-11-04,3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d53,25.00,Recurring,50.00,M,yes\r\n"Find contacts by a filter
POST /v1/contacts/search
Read access (ADR-020). The list builder's search: the contacts an unsaved filter (SavedListFilter) holds, as GET /v1/contacts gives them, a page at a time, sorted by last_name, first_name (org_name for organizations), then id. Stores nothing, as POST /v1/receipts/preview stores nothing. The filter is checked by the one grammar (422 with a pointer per problem, /filter/...); include=giving,total and include_deleted (admin only) are query parameters as on the list, and cursor pages with the same filter sent again. A filter with a giving condition loads the organization's gifts once and works them out in the money library, as the reports do: it draws on the organization's bucket of 30 such requests a minute (10 for one API key) and waits up to 5 seconds for the organization's other evaluations (429 with Retry-After past either), and answers within 10 seconds or 503.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
include | list of one of: giving, total | No | A comma-separated list of |
Request body (ContactSearchRequest)
| Name | Type | Required | Description |
|---|---|---|---|
filter | SavedListFilter | Yes | Who is on a list (ADR-020): a JSON document checked by one strict grammar wherever it is used (saving, searching, exporting and every read of a stored list). A condition is a leaf or one group of leaves, and any leaf takes |
Example request
{
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
}
}Responses
200A page of the contacts the filter holds.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Working out a list's giving conditions took longer than 10 seconds, the gift load and the money library included (ADR-020). Nothing was changed; try again, or narrow the list. Lists are built for organizations of up to 20,000 contacts and 100,000 gifts.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null,
"total": 1
}Subscribe a contact to email updates
POST /v1/contacts/{id}/email-subscription
Rule C (ADR-024). A signed-in staff member or admin subscribes directly, recording how the person agreed (200). An API key always sends the confirmation email instead (202), at most 50 a day per key (429 for the 51st). Anyone who unsubscribed is only ever sent the confirmation (202), whoever asks. 409 for a do-not-contact contact or one with no usable address. Emits contact.updated when the contact changed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailSubscriptionRequest)
| Name | Type | Required | Description |
|---|---|---|---|
agreed_how | one of: in_person, phone, paper_form | No | Signed-in staff: how the person agreed (required). An API key never sends it. |
note | string or null | No |
Example request
{
"agreed_how": "in_person",
"note": "Signed up at the spring open house"
}Responses
200Subscribed (or already was).202A confirmation email is on its way; nothing changes until the person confirms.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"outcome": "subscribed",
"contact": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"org_name": null,
"display_name": "Aiko Nakamura",
"email": "aiko.nakamura@example.com",
"phone": "+1 303 555 0142",
"household_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"employer": "Front Range Dental",
"do_not_contact": false,
"email_opt_in": true,
"sms_opt_in": false,
"source": "form",
"source_ref": "donation-form",
"consented": true,
"custom_fields": {
"volunteer": true,
"t_shirt_size": "M"
},
"tags": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"name": "Volunteer",
"color": "green"
}
],
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}
}List consent events
GET /v1/email-consent-events
The append-only consent log (ADR-024). Read access. Sorted by seq, oldest first, then id. contact_id keeps one contact's events. Consent events emit nothing. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
contact_id | string (uuid) | No |
Responses
200A page of events.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8c01",
"seq": 4182,
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"change": "subscribed",
"topic": null,
"source": "staff",
"wording": "Subscribed by staff, who recorded how the person agreed",
"box_ticked_by_default": null,
"agreed_how": "in_person",
"reason": "Signed up at the spring open house",
"user_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"import_job_id": null,
"email_signup_id": null,
"email_send_id": null,
"reverses_seq": null,
"backfilled": false,
"stated_at": null,
"recorded_at": "2026-10-13T15:00:00Z"
}
],
"next_cursor": null
}Custom fields
The fields an organization defines for its contacts (ADR-018) - text, number, date, yes/no or a choice from a list. Values live on each contact in custom_fields under the field's key; a write is checked only for the defined keys whose value it adds or changes, and keys with no definition (kept by the importer) are left as they are. Defining a key that already holds imported values reports the values that do not fit instead of refusing anything. Admins manage the fields; every change is audited and emits custom_field_definition.created, .updated (an unarchive too, as a restore does) or .deleted (archive, this resource's delete). A field is archived, never deleted, so no saved value loses its meaning.
List custom fields
GET /v1/custom-field-definitions
Read access. Sorted by position, then id. Archived fields are excluded unless include_archived=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_archived | boolean | No | When true, archived custom fields are listed too ( |
Responses
200A page of custom fields.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b01",
"key": "t_shirt_size",
"label": "T-shirt size",
"type": "choice",
"choices": [
"S",
"M",
"L",
"XL"
],
"position": 0,
"value_count": 41,
"mismatch_count": 2,
"archived_at": null,
"archived_by": null,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z"
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b02",
"key": "volunteer",
"label": "Volunteers with us",
"type": "yes_no",
"choices": null,
"position": 1,
"value_count": 23,
"mismatch_count": 0,
"archived_at": null,
"archived_by": null,
"created_at": "2026-09-01T16:05:00Z",
"updated_at": "2026-09-01T16:05:00Z"
}
],
"next_cursor": null
}Define a custom field
POST /v1/custom-field-definitions
Admin only (a signed-in admin; API keys get 403, ADR-006). Defines what the contact page shows and edits under key. Values already saved under the key (an imported column) are kept as they are: the answer's value_count and mismatch_count say how many contacts hold one and how many of those do not fit, and GET /v1/custom-field-definitions/{id}/mismatches lists them, so nothing breaks until someone edits them. 409 when the organization has used the key before (archived fields keep theirs) or a field in use has the label. Unknown fields are a 422. Audited; emits custom_field_definition.created.
Request body (CustomFieldDefinitionCreate)
| Name | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Lowercase letters, digits and underscores, starting with a letter or digit; at most 64. The key of values already saved (an imported column, say) defines them. |
label | string | Yes | Trimmed; one line. |
type | one of: text, number, date, yes_no, choice | Yes | What a custom field holds, and the JSON a contact's |
choices | list of string | No | Required for a |
position | integer | No | Where the field goes in the order; omit to put it last. The fields after it move down one. |
Example request
{
"key": "t_shirt_size",
"label": "T-shirt size",
"type": "choice",
"choices": [
"S",
"M",
"L",
"XL"
]
}Responses
201The new custom field.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b01",
"key": "t_shirt_size",
"label": "T-shirt size",
"type": "choice",
"choices": [
"S",
"M",
"L",
"XL"
],
"position": 0,
"value_count": 41,
"mismatch_count": 2,
"archived_at": null,
"archived_by": null,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z"
}Get a custom field
GET /v1/custom-field-definitions/{id}
An archived field is returned too, with archived_at set.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The custom field.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b01",
"key": "t_shirt_size",
"label": "T-shirt size",
"type": "choice",
"choices": [
"S",
"M",
"L",
"XL"
],
"position": 0,
"value_count": 41,
"mismatch_count": 2,
"archived_at": null,
"archived_by": null,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z"
}Rename, reorder or retype a custom field
PATCH /v1/custom-field-definitions/{id}
Admin only. The label, the position and a choice field's choices change freely, except that a choice a live contact holds cannot be removed (409 naming it). The type changes only while no live contact holds a value (409 otherwise: archive the field and define a new one). The key never changes (422). 409 when a field in use has the label. Moving a field moves the ones in between, each audited and emitting custom_field_definition.updated, as the field itself does.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (CustomFieldDefinitionUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
label | string | No | Trimmed; one line. |
type | one of: text, number, date, yes_no, choice | No | What a custom field holds, and the JSON a contact's |
choices | list of string | No | A |
position | integer | No | Moves the field to this place in the order (0 first, past the end means last); the fields in between shift by one. |
Example request
{
"label": "Shirt size",
"choices": [
"XS",
"S",
"M",
"L",
"XL"
]
}Responses
200The updated custom field.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b01",
"key": "t_shirt_size",
"label": "Shirt size",
"type": "choice",
"choices": [
"XS",
"S",
"M",
"L",
"XL"
],
"position": 0,
"value_count": 41,
"mismatch_count": 2,
"archived_at": null,
"archived_by": null,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}Archive a custom field
POST /v1/custom-field-definitions/{id}/archive
Admin only. Archiving is this resource's delete (ADR-005: nothing is removed): the field leaves the contact page's editing and the importer's suggestions, every saved value stays on its contacts and the key stays the organization's. Undo with POST /v1/custom-field-definitions/{id}/unarchive. 409 if it is already archived. Audited; emits custom_field_definition.deleted with archived_at set.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The archived custom field.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b03",
"key": "newsletter_format",
"label": "Newsletter format",
"type": "text",
"choices": null,
"position": 2,
"value_count": 12,
"mismatch_count": 0,
"archived_at": "2026-10-05T16:00:00Z",
"archived_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-05T16:00:00Z"
}Unarchive a custom field
POST /v1/custom-field-definitions/{id}/unarchive
Admin only. The field is back on the contact page in its old place. 409 if it is not archived, or if a field in use now has its label (rename one of them first). Audited; emits custom_field_definition.updated, as a restore does (ADR-005).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The custom field, with `archived_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7b03",
"key": "newsletter_format",
"label": "Newsletter format",
"type": "text",
"choices": null,
"position": 2,
"value_count": 12,
"mismatch_count": 0,
"archived_at": null,
"archived_by": null,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-06T09:00:00Z"
}List the values that do not fit a custom field
GET /v1/custom-field-definitions/{id}/mismatches
Read access. The live contacts whose saved value under the field's key does not fit its type or choices (ADR-018): values imported before the field was defined, say. Nothing is refused because of them; editing the contact fixes each. Sorted by display_name (case-insensitive), then id. mismatch_count on the field is the total.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
Responses
200A page of contacts and their values.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"display_name": "Ben Nakamura",
"value": "Medium"
},
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d81",
"display_name": "Priya Shah",
"value": 2
}
],
"next_cursor": null
}Saved lists
Saved lists (ADR-020): a filter over contacts - giving (gave in a period, total in a period, first, last and largest gift, lifetime, fund, campaign, appeal), tags, custom fields, place and preferences - saved under a name and worked out whenever it is read, so "gave last fiscal year" rolls over by itself. Giving conditions use the money library's rules exactly, the reports' own, so a list agrees with the reports for the same data. A snapshot freezes a list's members for an appeal or a bulk action; a bulk run tags, untags or deletes a snapshot's members 500 a call and can be undone from its history with no time limit. Lists emit saved_list.created, .updated (a restore too) and .deleted; a snapshot emits saved_list_snapshot.created; membership changes emit nothing.
List saved lists
GET /v1/saved-lists
Read access. Sorted by name (case-insensitive), then id. Each list carries its problems (empty when it can be worked out). Soft-deleted lists are excluded unless include_deleted=true (admin only). Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
q | string | No | Part of the name, case-insensitive. |
Responses
200A page of saved lists.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"name": "Lapsed shelter donors",
"description": "Gave to the shelter last fiscal year, not yet this year, and can be mailed.",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"version": 3,
"problems": [],
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Save a list
POST /v1/saved-lists
Staff, admins and write keys (ADR-020). A list is a rule: its members are worked out whenever it is read, so "gave last fiscal year" rolls over by itself and a gift recorded a minute ago counts. The name is trimmed and unique among live lists whatever the case (409). The filter is checked whole by the one grammar (422 with a pointer per problem, /filter/...). version starts at 1. Lists belong to the organization: anyone with write access may edit or delete any list, and the audit log says who changed what. Emits saved_list.created.
Request body (SavedListCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Trimmed; one line. |
description | string or null | No | |
filter | SavedListFilter | Yes | Who is on a list (ADR-020): a JSON document checked by one strict grammar wherever it is used (saving, searching, exporting and every read of a stored list). A condition is a leaf or one group of leaves, and any leaf takes |
Example request
{
"name": "Lapsed shelter donors",
"description": "Gave to the shelter last fiscal year, not yet this year, and can be mailed.",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
}
}Responses
201The saved list.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"name": "Lapsed shelter donors",
"description": "Gave to the shelter last fiscal year, not yet this year, and can be mailed.",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"version": 3,
"problems": [],
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Get a saved list
GET /v1/saved-lists/{id}
Read access. A soft-deleted list is still returned, with deleted_at set, so undo screens can show it. include=count adds member_count, how many live contacts are on it now: one count(*) over the condition GET /v1/contacts?saved_list_id= pages, so the two agree for the same data. A count of a list with a giving condition draws on the organization's evaluation bucket (429) and answers within 10 seconds (503); a list with problems, or a deleted one, answers 409 for the count. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
include | one of: count | No |
|
Responses
200The saved list.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409The saved list can't be worked out now (ADR-020): a condition names a tag, fund, campaign or appeal deleted since, a custom field whose type no longer allows it, or the stored filter no longer passes the grammar; or the list is deleted. Type `https://docs.operationcharity.org/problems/saved-list-needs-fixing`, with a pointer and a message per condition in `errors`. Restore the record or edit the list. Nothing was changed.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Working out a list's giving conditions took longer than 10 seconds, the gift load and the money library included (ADR-020). Nothing was changed; try again, or narrow the list. Lists are built for organizations of up to 20,000 contacts and 100,000 gifts.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"name": "Lapsed shelter donors",
"description": "Gave to the shelter last fiscal year, not yet this year, and can be mailed.",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"version": 3,
"problems": [],
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Rename or change a saved list
PATCH /v1/saved-lists/{id}
Staff, admins and write keys. filter replaces the stored filter whole and is checked whole (422 with pointers); a change of filter increments version, and a save of the same filter keeps it. Of two saves at once the later wins, and the audit log keeps both. Snapshots already frozen do not change. Emits saved_list.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (SavedListUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | Trimmed; one line. |
description | string or null | No | |
filter | SavedListFilter | No | Who is on a list (ADR-020): a JSON document checked by one strict grammar wherever it is used (saving, searching, exporting and every read of a stored list). A condition is a leaf or one group of leaves, and any leaf takes |
Example request
{
"name": "Lapsed shelter donors (mail)"
}Responses
200The updated list.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"name": "Lapsed shelter donors",
"description": "Gave to the shelter last fiscal year, not yet this year, and can be mailed.",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"version": 3,
"problems": [],
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a saved list
DELETE /v1/saved-lists/{id}
Staff, admins and write keys. Soft delete (ADR-005): the list leaves the list of lists and is no longer worked out (its members and count answer 409); its snapshots and runs stay, and POST /v1/saved-lists/{id}/restore brings it back. Emits saved_list.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted saved list
POST /v1/saved-lists/{id}/restore
Staff, admins and write keys. Clears deleted_at and deleted_by. 409 if the list is not deleted, or if another live list now has its name (rename one of them first). Emits saved_list.updated, as every restore does (ADR-005).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored list, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"name": "Lapsed shelter donors",
"description": "Gave to the shelter last fiscal year, not yet this year, and can be mailed.",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 1
},
"to": {
"type": "year_end",
"years_ago": 1
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "year_start",
"years_ago": 0
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"version": 3,
"problems": [],
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Freeze a saved list's members
POST /v1/saved-lists/{id}/snapshots
Staff, admins and write keys (ADR-020). Freezes the list's live members of this moment in one statement, with the filter as evaluated (every relative date resolved against as_of, the organization's date), the list's version and the count, so the count can never disagree with the rows; a snapshot never changes. F1 sends to the snapshot its approver reviewed, and bulk actions over a list act on a snapshot. 409 while the list has problems, or when it is deleted. A list with a giving condition draws on the organization's evaluation bucket (429) and answers within 10 seconds (503). Emits saved_list_snapshot.created, without its members.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (SavedListSnapshotCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | Optional; trimmed, one line. |
Example request
{
"name": "November appeal"
}Responses
201The snapshot.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409The saved list can't be worked out now (ADR-020): a condition names a tag, fund, campaign or appeal deleted since, a custom field whose type no longer allows it, or the stored filter no longer passes the grammar; or the list is deleted. Type `https://docs.operationcharity.org/problems/saved-list-needs-fixing`, with a pointer and a message per condition in `errors`. Restore the record or edit the list. Nothing was changed.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Working out a list's giving conditions took longer than 10 seconds, the gift load and the money library included (ADR-020). Nothing was changed; try again, or narrow the list. Lists are built for organizations of up to 20,000 contacts and 100,000 gifts.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"list_version": 3,
"name": "November appeal",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "date",
"date": "2025-07-01"
},
"to": {
"type": "date",
"date": "2026-06-30"
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "date",
"date": "2026-07-01"
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"as_of": "2026-10-13",
"member_count": 112,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-13T15:00:00Z"
}List snapshots
GET /v1/saved-list-snapshots
Read access. Sorted by created_at, newest first, then id. saved_list_id keeps one list's snapshots. Their members are GET /v1/contacts?snapshot_id=. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
saved_list_id | string (uuid) | No |
Responses
200A page of snapshots.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"list_version": 3,
"name": "November appeal",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "date",
"date": "2025-07-01"
},
"to": {
"type": "date",
"date": "2026-06-30"
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "date",
"date": "2026-07-01"
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"as_of": "2026-10-13",
"member_count": 112,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-13T15:00:00Z"
}
],
"next_cursor": null
}Get a snapshot
GET /v1/saved-list-snapshots/{id}
Read access. A snapshot of a list deleted since is still returned. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The snapshot.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"list_version": 3,
"name": "November appeal",
"filter": {
"version": 1,
"match": "all",
"conditions": [
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "date",
"date": "2025-07-01"
},
"to": {
"type": "date",
"date": "2026-06-30"
}
},
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
]
},
{
"field": "gave",
"period": {
"basis": "fiscal",
"from": {
"type": "date",
"date": "2026-07-01"
},
"to": null
},
"not": true
},
{
"field": "has_mailing_address"
},
{
"field": "do_not_contact",
"not": true
},
{
"field": "tags",
"match": "none",
"tag_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02"
]
}
]
},
"as_of": "2026-10-13",
"member_count": 112,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-13T15:00:00Z"
}List bulk runs
GET /v1/bulk-runs
Read access. The history of bulk actions over snapshots (ADR-020), each with its status, so any of them can be undone with no time limit. Sorted by created_at, newest first, then id. snapshot_id keeps one snapshot's runs. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
snapshot_id | string (uuid) | No |
Responses
200A page of runs.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7e01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"action": "add_tag",
"tag_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"status": "completed",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"api_key_id": null,
"call_count": 1,
"changed_count": 104,
"created_at": "2026-10-13T15:05:00Z",
"completed_at": "2026-10-13T15:05:00Z",
"undo_started_at": null,
"undone_by": null,
"undone_by_api_key_id": null,
"undone_at": null,
"cursor": null
}
],
"next_cursor": null
}Get a bulk run
GET /v1/bulk-runs/{id}
Read access. While the run is running, whoever started it gets a fresh cursor, so a run left longer than a cursor lives (24 hours) can continue with POST /v1/contacts/bulk; anyone else, and any run that is not running, gets null. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The run.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7e01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"action": "add_tag",
"tag_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"status": "completed",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"api_key_id": null,
"call_count": 1,
"changed_count": 104,
"created_at": "2026-10-13T15:05:00Z",
"completed_at": "2026-10-13T15:05:00Z",
"undo_started_at": null,
"undone_by": null,
"undone_by_api_key_id": null,
"undone_at": null,
"cursor": null
}Undo a bulk run
POST /v1/bulk-runs/{id}/undo
Reverses exactly the contacts the run recorded as changed, 500 a call in contact id order, from the run's own history (no time limit, as an import's rollback; the calls' stored responses are not needed). It needs the access starting the run did: write access (staff, admins, write keys) for a tagging run, a signed-in admin for a delete run (API keys and staff get 403). The first call moves the run to undoing, so a run still in progress takes no more calls; call again while it answers undoing, until undone. Each call locks the run first. Undoing an add-tag run takes the tag off a contact only while that tag row is live and unchanged since the run's call; undoing a remove-tag run puts the tag back only where it is still off and unchanged since the run's call; undoing a delete run restores a contact only while it is still deleted with its call's time, with its communications and tasks, and a relationship once both its contacts are live (ADR-018). A contact changed since is unchanged and left as it is, and so is a contact whose custom fields are still an old import's text, with that reason. An undone run answers 409, and so does a tagging run whose tag was deleted, until the tag is restored. Each contact put back emits its own contact.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200What this call reversed, and the run.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"bulk_run": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7e01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"action": "add_tag",
"tag_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
"status": "undone",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"api_key_id": null,
"call_count": 1,
"changed_count": 2,
"created_at": "2026-10-13T15:05:00Z",
"completed_at": "2026-10-13T15:05:00Z",
"undo_started_at": "2026-10-13T15:20:00Z",
"undone_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"undone_by_api_key_id": null,
"undone_at": "2026-10-13T15:20:00Z",
"cursor": null
},
"results": [
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"outcome": "changed",
"reason": null
},
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d81",
"outcome": "unchanged",
"reason": "The tag was changed on this contact since the run, so it was left as it is."
}
],
"counts": {
"changed": 1,
"unchanged": 1
}
}Households
List households
GET /v1/households
Sorted by name, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
q | string | No | Matches the household name. |
updated_since | string (date-time) | No | Only records updated at or after this timestamp (UTC). |
Responses
200A page of households.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"name": "The Nakamura household",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-03-01T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a household
POST /v1/households
Emits household.created.
Request body (HouseholdCreate)
| Name | Type | Required | Description |
|---|---|---|---|
address_line1 | string or null | No | |
address_line2 | string or null | No | |
city | string or null | No | |
region | string or null | No | State or province. |
postal_code | string or null | No | |
country | string or null | No | |
name | string | Yes |
Example request
{
"name": "The Nakamura household",
"address_line1": "1420 Spruce St",
"city": "Denver",
"region": "CO",
"postal_code": "80205"
}Responses
201The new household.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"name": "The Nakamura household",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-03-01T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a household
GET /v1/households/{id}
A soft-deleted household is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The household.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"name": "The Nakamura household",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-03-01T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a household
PATCH /v1/households/{id}
Emits household.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (HouseholdUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
address_line1 | string or null | No | |
address_line2 | string or null | No | |
city | string or null | No | |
region | string or null | No | State or province. |
postal_code | string or null | No | |
country | string or null | No | |
name | string | No |
Example request
{
"address_line1": "1420 Spruce St",
"postal_code": "80205"
}Responses
200The updated household.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"name": "The Nakamura household",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-03-01T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a household
DELETE /v1/households/{id}
Soft delete (ADR-005): sets deleted_at, hides the household from lists and keeps members' household_id intact so a restore puts the household back together. Emits household.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted household
POST /v1/households/{id}/restore
Clears deleted_at and deleted_by. 409 if the household is not deleted. Emits household.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored household, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d04",
"name": "The Nakamura household",
"address_line1": "1420 Spruce St",
"address_line2": null,
"city": "Denver",
"region": "CO",
"postal_code": "80205",
"country": "US",
"created_at": "2026-03-01T15:00:00Z",
"updated_at": "2026-03-01T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Contact relationships
List contact relationships
GET /v1/contact-relationships
Sorted by created_at, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
contact_id | string (uuid) | No | Relationships where this contact is on either side. |
relationship | string | No |
Responses
200A page of relationships.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d23",
"from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"relationship": "spouse",
"created_at": "2026-03-01T15:05:00Z",
"updated_at": "2026-03-01T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a contact relationship
POST /v1/contact-relationships
Directional (from → to). Record the reverse separately if needed. Emits contact_relationship.created.
Request body (ContactRelationshipCreate)
| Name | Type | Required | Description |
|---|---|---|---|
from_contact_id | string (uuid) | Yes | |
to_contact_id | string (uuid) | Yes | |
relationship | string | Yes |
Example request
{
"from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"relationship": "spouse"
}Responses
201The new relationship.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d23",
"from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"relationship": "spouse",
"created_at": "2026-03-01T15:05:00Z",
"updated_at": "2026-03-01T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}Get a contact relationship
GET /v1/contact-relationships/{id}
A soft-deleted contact relationship is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The relationship.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d23",
"from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"relationship": "spouse",
"created_at": "2026-03-01T15:05:00Z",
"updated_at": "2026-03-01T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}Update a contact relationship
PATCH /v1/contact-relationships/{id}
Only the label can change. Emits contact_relationship.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (ContactRelationshipUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
relationship | string | Yes |
Example request
{
"relationship": "partner"
}Responses
200The updated relationship.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d23",
"from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"relationship": "spouse",
"created_at": "2026-03-01T15:05:00Z",
"updated_at": "2026-03-01T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a contact relationship
DELETE /v1/contact-relationships/{id}
Soft delete (ADR-005): sets deleted_at; restore with POST /v1/contact-relationships/{id}/restore. Emits contact_relationship.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted contact relationship
POST /v1/contact-relationships/{id}/restore
Clears deleted_at and deleted_by. 409 if the contact relationship is not deleted. Emits contact_relationship.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored contact relationship, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d23",
"from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"relationship": "spouse",
"created_at": "2026-03-01T15:05:00Z",
"updated_at": "2026-03-01T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}Funds
List funds
GET /v1/funds
Sorted by name, then id. Inactive funds are included unless active=true. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
restriction | one of: unrestricted, purpose, time, perpetual | No | |
active | boolean | No |
Responses
200A page of funds.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"name": "General fund",
"code": "GEN",
"restriction": "unrestricted",
"purpose": null,
"restriction_release_date": null,
"is_default": true,
"active": true,
"created_at": "2026-01-15T16:00:00Z",
"updated_at": "2026-01-15T16:00:00Z",
"deleted_at": null,
"deleted_by": null
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Winter Shelter Fund",
"code": "SHELTER",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March",
"restriction_release_date": null,
"is_default": false,
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a fund
POST /v1/funds
Names are unique per organization (409). A purpose-restricted fund should state its purpose; a time-restricted fund its release date. Emits fund.created.
Request body (FundCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | |
code | string or null | No | |
restriction | one of: unrestricted, purpose, time, perpetual | No | |
purpose | string or null | No | |
restriction_release_date | string (date) or null | No | |
active | boolean | No |
Example request
{
"name": "Winter Shelter Fund",
"code": "SHELTER",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March"
}Responses
201The new fund.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Winter Shelter Fund",
"code": "SHELTER",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March",
"restriction_release_date": null,
"is_default": false,
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a fund
GET /v1/funds/{id}
A soft-deleted fund is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The fund.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Winter Shelter Fund",
"code": "SHELTER",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March",
"restriction_release_date": null,
"is_default": false,
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a fund
PATCH /v1/funds/{id}
Changing restriction on a fund that has gifts is refused (409). Emits fund.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (FundUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | |
code | string or null | No | |
restriction | one of: unrestricted, purpose, time, perpetual | No | |
purpose | string or null | No | |
restriction_release_date | string (date) or null | No | |
active | boolean | No |
Example request
{
"purpose": "Overnight shelter operations from November through March"
}Responses
200The updated fund.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Winter Shelter Fund",
"code": "SHELTER",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March",
"restriction_release_date": null,
"is_default": false,
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a fund
DELETE /v1/funds/{id}
Soft delete (ADR-005): sets deleted_at and hides the fund from lists; restore with POST /v1/funds/{id}/restore. Still refused (409) for the default fund or any fund with gifts, so balances and reports stay intact; set active=false instead. Also refused (409, ADR-013) while a live donation form offers the fund or a recurring donation that has not ended gives to it; the detail names the form. Emits fund.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Fund ledger
GET /v1/funds/{id}/ledger
Every gift into the fund and every adjustment that changes its balance between date_from and date_to (inclusive; date_to defaults to today in the organization's timezone, date_from to the beginning), with a running balance, so a bookkeeper can tie out the restricted-balances report (B6). opening_balance_cents carries everything before date_from; closing_balance_cents equals the report's remaining_cents with as_of = date_to. Same arithmetic as the report and hard credit only. Sorted by date ascending, then gifts before adjustments, then id (then line type, since a reallocation within one fund has two lines); balance_cents is the running balance in that order. A soft-deleted fund still answers.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
Responses
200A page of ledger lines.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"restriction": "purpose",
"date_from": "2026-10-01",
"date_to": "2026-10-31",
"opening_balance_cents": 840000,
"closing_balance_cents": 850000,
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"source": "gift",
"type": "gift",
"date": "2026-10-02",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"adjustment_id": null,
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Tanaka",
"amount_cents": 25000,
"balance_cents": 865000,
"counterpart_fund_id": null,
"reason": null,
"exception_reason": null,
"consent_reference": null
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"source": "adjustment",
"type": "reallocate_out",
"date": "2026-10-05",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"adjustment_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Tanaka",
"amount_cents": -5000,
"balance_cents": 860000,
"counterpart_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"reason": "Donor asked to move part of the gift",
"exception_reason": null,
"consent_reference": "Donor email of 2026-10-04"
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"source": "adjustment",
"type": "release_restriction",
"date": "2026-10-20",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"adjustment_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"contact_name": "Cedar Ridge Community Foundation",
"amount_cents": -10000,
"balance_cents": 850000,
"counterpart_fund_id": null,
"reason": "October shelter staffing paid",
"exception_reason": null,
"consent_reference": null
}
],
"next_cursor": null
}Restore a deleted fund
POST /v1/funds/{id}/restore
Clears deleted_at and deleted_by. 409 if the fund is not deleted. Emits fund.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored fund, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Winter Shelter Fund",
"code": "SHELTER",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March",
"restriction_release_date": null,
"is_default": false,
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Campaigns
List campaigns
GET /v1/campaigns
Sorted by starts_on descending (nulls last), then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
q | string | No | Matches the campaign name. |
active_on | string (date) | No | Campaigns whose date range includes this date. |
Responses
200A page of campaigns.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "Winter 2026",
"goal_cents": 5000000,
"starts_on": "2026-10-01",
"ends_on": "2026-12-31",
"default_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"created_at": "2026-09-15T16:00:00Z",
"updated_at": "2026-09-15T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a campaign
POST /v1/campaigns
Names are unique per organization (409). Emits campaign.created.
Request body (CampaignCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | |
goal_cents | integer (int64) or null | No | Integer cents |
starts_on | string (date) or null | No | |
ends_on | string (date) or null | No | |
default_fund_id | string (uuid) or null | No |
Example request
{
"name": "Winter 2026",
"goal_cents": 5000000,
"starts_on": "2026-10-01",
"ends_on": "2026-12-31",
"default_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09"
}Responses
201The new campaign.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "Winter 2026",
"goal_cents": 5000000,
"starts_on": "2026-10-01",
"ends_on": "2026-12-31",
"default_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"created_at": "2026-09-15T16:00:00Z",
"updated_at": "2026-09-15T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a campaign
GET /v1/campaigns/{id}
A soft-deleted campaign is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The campaign.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "Winter 2026",
"goal_cents": 5000000,
"starts_on": "2026-10-01",
"ends_on": "2026-12-31",
"default_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"created_at": "2026-09-15T16:00:00Z",
"updated_at": "2026-09-15T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a campaign
PATCH /v1/campaigns/{id}
Emits campaign.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (CampaignUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | |
goal_cents | integer (int64) or null | No | Integer cents |
starts_on | string (date) or null | No | |
ends_on | string (date) or null | No | |
default_fund_id | string (uuid) or null | No |
Example request
{
"goal_cents": 5000000
}Responses
200The updated campaign.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "Winter 2026",
"goal_cents": 5000000,
"starts_on": "2026-10-01",
"ends_on": "2026-12-31",
"default_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"created_at": "2026-09-15T16:00:00Z",
"updated_at": "2026-09-15T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a campaign
DELETE /v1/campaigns/{id}
Soft delete (ADR-005): sets deleted_at and hides the campaign from lists. Gifts and appeals keep their campaign_id so a restore is a true undo. Refused (409, ADR-013) while a live donation form is set to the campaign or a recurring donation that has not ended is credited to it; the detail names the form. Emits campaign.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted campaign
POST /v1/campaigns/{id}/restore
Clears deleted_at and deleted_by. 409 if the campaign is not deleted. Emits campaign.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored campaign, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "Winter 2026",
"goal_cents": 5000000,
"starts_on": "2026-10-01",
"ends_on": "2026-12-31",
"default_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"created_at": "2026-09-15T16:00:00Z",
"updated_at": "2026-09-15T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Appeals
List appeals
GET /v1/appeals
Sorted by sent_on descending (nulls last), then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
campaign_id | string (uuid) | No | |
q | string | No | Matches the appeal name. |
Responses
200A page of appeals.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "September letter",
"channel": "letter",
"sent_on": "2026-09-28",
"cost_cents": 84000,
"created_at": "2026-09-15T17:00:00Z",
"updated_at": "2026-09-28T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create an appeal
POST /v1/appeals
Names are unique per organization (409). Emits appeal.created.
Request body (AppealCreate)
| Name | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) or null | No | |
name | string | Yes | |
channel | string or null | No | |
sent_on | string (date) or null | No | |
cost_cents | integer (int64) | No | Integer cents |
Example request
{
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "September letter",
"channel": "letter",
"sent_on": "2026-09-28",
"cost_cents": 84000
}Responses
201The new appeal.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "September letter",
"channel": "letter",
"sent_on": "2026-09-28",
"cost_cents": 84000,
"created_at": "2026-09-15T17:00:00Z",
"updated_at": "2026-09-28T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get an appeal
GET /v1/appeals/{id}
A soft-deleted appeal is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The appeal.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "September letter",
"channel": "letter",
"sent_on": "2026-09-28",
"cost_cents": 84000,
"created_at": "2026-09-15T17:00:00Z",
"updated_at": "2026-09-28T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update an appeal
PATCH /v1/appeals/{id}
Emits appeal.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (AppealUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) or null | No | |
name | string | No | |
channel | string or null | No | |
sent_on | string (date) or null | No | |
cost_cents | integer (int64) | No | Integer cents |
Example request
{
"cost_cents": 84000,
"sent_on": "2026-09-28"
}Responses
200The updated appeal.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "September letter",
"channel": "letter",
"sent_on": "2026-09-28",
"cost_cents": 84000,
"created_at": "2026-09-15T17:00:00Z",
"updated_at": "2026-09-28T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete an appeal
DELETE /v1/appeals/{id}
Soft delete (ADR-005): sets deleted_at and hides the appeal from lists. Gifts keep their appeal_id so a restore is a true undo. Refused (409, ADR-013) while a live donation form is set to the appeal or a recurring donation that has not ended is credited to it; the detail names the form. Emits appeal.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted appeal
POST /v1/appeals/{id}/restore
Clears deleted_at and deleted_by. 409 if the appeal is not deleted. Emits appeal.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored appeal, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"name": "September letter",
"channel": "letter",
"sent_on": "2026-09-28",
"cost_cents": 84000,
"created_at": "2026-09-15T17:00:00Z",
"updated_at": "2026-09-28T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Gifts
Gifts are append-only. There is no update or delete; corrections are gift adjustments.
List gifts
GET /v1/gifts
Sorted by received_on descending, then id descending. donation_form_id lists the gifts made through one donation form; recurring_donation_id lists the charges of one recurring donation (ADR-013); batch_id lists every gift entered into one deposit batch, voided ones included (ADR-019; GET /v1/gift-batches/{id}/entries lists them in the order entered, as the batch counts them).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
contact_id | string (uuid) | No | |
fund_id | string (uuid) | No | |
campaign_id | string (uuid) | No | |
appeal_id | string (uuid) | No | |
grant_id | string (uuid) | No | |
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
kind | one of: one_time, recurring, pledge, pledge_payment, in_kind, grant_payment | No | |
status | one of: posted, refunded, voided | No | |
donation_form_id | string (uuid) | No | |
recurring_donation_id | string (uuid) | No | |
batch_id | string (uuid) | No | |
email_send_id | string (uuid) | No | Gifts through an email send's donation link (ADR-023): the checkouts its |
Responses
200A page of gifts.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"campaign_name": "Winter 2026",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"appeal_name": "September letter",
"grant_id": null,
"kind": "one_time",
"status": "posted",
"amount_cents": 5000,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-10-04",
"contribution_date": "2026-10-04",
"payment_method": "card",
"external_ref": "pi_3Q8kLm2eZvKYlo2C1aBcDeFg",
"in_kind_description": null,
"property_description": null,
"book_fair_value_cents": null,
"valuation_method": null,
"nonfinancial_category": null,
"pledge_id": null,
"tribute_type": null,
"tribute_name": null,
"notify_contact_id": null,
"notes": "Gave through the website after the September letter.",
"receipt_required": false,
"donation_form_id": null,
"donation_form_title": null,
"recurring_donation_id": null,
"recurring_interval": null,
"batch_id": null,
"batch_name": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z"
}
],
"next_cursor": null
}Record a gift
POST /v1/giftsIdempotency-Key required
Gifts are append-only: there is no PATCH or DELETE /v1/gifts/{id}. To refund, void, correct an amount, move money between funds or release a restriction, create a gift adjustment. Requires Idempotency-Key. Creates the thank-you task and, when the amount is $250 or more, flags the receipt as required. Emits gift.created. With batch_id the gift is entered into an open deposit batch (ADR-019): it must have an amount (never in-kind) and may not be a pledge; a missing or deleted batch, or a gift that cannot join, is 422 on /batch_id, and a closed batch is 409. A batch holds checks and cash only: any other payment method, or none, is 422 on /payment_method. A gift received after the batch's deposit date is 422 on /received_on (received on the deposit date itself, it joins). A check's number goes in external_ref: 1 to 10 digits, for every check gift in a batch or not; anything else (such as the line along the bottom of the check, which holds the donor's bank account numbers) is 422 on /external_ref. Cash in a batch carries no other reference either.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (GiftCreate)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) | Yes | |
fund_id | string (uuid) | Yes | Fund and restriction dimension. Required on every gift. |
campaign_id | string (uuid) or null | No | |
appeal_id | string (uuid) or null | No | |
grant_id | string (uuid) or null | No | Set on grant_payment gifts. |
kind | one of: one_time, recurring, pledge, pledge_payment, in_kind, grant_payment | No | |
amount_cents | integer (int64) or null | No | Integer cents. Required unless kind is in_kind; null or omitted for in_kind. |
fair_market_value_cents | integer (int64) | No | Integer cents of goods or services the donor received (quid pro quo). 0 for a pure donation. |
fee_covered_cents | integer (int64) | No | Integer cents of a processing fee the donor chose to cover, inside amount_cents; at most amount_cents, and 0 on an in-kind gift (ADR-007, E7.1). |
received_on | string (date) | Yes | |
contribution_date | string (date) or null | No | Postmark, card charge or transfer date. Defaults to received_on. |
payment_method | string or null | No | check, card, ach, cash, stock or in_kind. |
external_ref | string or null | No | Stripe payment id or check number. When |
in_kind_description | string or null | No | Staff description of donated property. |
property_description | string or null | No | What the receipt prints for an in-kind gift (described, never valued). Defaults to in_kind_description. |
book_fair_value_cents | integer (int64) or null | No | Integer cents. Internal only: the organization's book value of donated property. Shown to staff, never printed on a receipt or counted in any receipt or statement total. In-kind gifts only. |
valuation_method | string or null | No | Internal only: how the book value was set (for example comparable sales, appraisal, donor estimate). In-kind gifts only. |
nonfinancial_category | string or null | No | Internal only: Form 990 Schedule M / ASU 2020-07 category of the contributed nonfinancial asset (for example food, clothing, equipment, services). In-kind gifts only. |
pledge_id | string (uuid) or null | No | The pledge this pledge_payment fulfils. |
tribute_type | string or null | No | in_honor_of or in_memory_of. |
tribute_name | string or null | No | |
notify_contact_id | string (uuid) or null | No | |
notes | string or null | No | |
batch_id | string (uuid) or null | No | Enter the gift into an open deposit batch of this organization (ADR-019). The gift must have an amount (never in-kind) and may not be a pledge (a promise, not money); a missing or deleted batch, or a gift that cannot join, is 422 on |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"kind": "one_time",
"amount_cents": 5000,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-10-04",
"payment_method": "card",
"external_ref": "pi_3Q8kLm2eZvKYlo2C1aBcDeFg",
"notes": "Gave through the website after the September letter."
}Responses
201The recorded gift.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"campaign_name": "Winter 2026",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"appeal_name": "September letter",
"grant_id": null,
"kind": "one_time",
"status": "posted",
"amount_cents": 5000,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-10-04",
"contribution_date": "2026-10-04",
"payment_method": "card",
"external_ref": "pi_3Q8kLm2eZvKYlo2C1aBcDeFg",
"in_kind_description": null,
"property_description": null,
"book_fair_value_cents": null,
"valuation_method": null,
"nonfinancial_category": null,
"pledge_id": null,
"tribute_type": null,
"tribute_name": null,
"notify_contact_id": null,
"notes": "Gave through the website after the September letter.",
"receipt_required": false,
"donation_form_id": null,
"donation_form_title": null,
"recurring_donation_id": null,
"recurring_interval": null,
"batch_id": null,
"batch_name": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z"
}Get a gift
GET /v1/gifts/{id}
Online gifts (ADR-013) carry donation_form_id, recurring_donation_id for each charge of a recurring donation, the Stripe payment intent in external_ref and created_by null. A gift entered into a deposit batch (ADR-019) carries batch_id and batch_name, and a check its number in external_ref.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The gift.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"campaign_name": "Winter 2026",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"appeal_name": "September letter",
"grant_id": null,
"kind": "one_time",
"status": "posted",
"amount_cents": 5000,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-10-04",
"contribution_date": "2026-10-04",
"payment_method": "card",
"external_ref": "pi_3Q8kLm2eZvKYlo2C1aBcDeFg",
"in_kind_description": null,
"property_description": null,
"book_fair_value_cents": null,
"valuation_method": null,
"nonfinancial_category": null,
"pledge_id": null,
"tribute_type": null,
"tribute_name": null,
"notify_contact_id": null,
"notes": "Gave through the website after the September letter.",
"receipt_required": false,
"donation_form_id": null,
"donation_form_title": null,
"recurring_donation_id": null,
"recurring_interval": null,
"batch_id": null,
"batch_name": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z"
}Update an in-kind gift's valuation
PATCH /v1/gifts/{id}/valuation
Sets the internal valuation (book_fair_value_cents, valuation_method, nonfinancial_category) or the printed property_description of a posted in-kind gift, for example when an appraisal arrives (ADR-008). These are the only gift columns the append-only guard lets change; the change is written to the audit log and emits gift.updated. 422 for a cash gift; 409 when the gift is no longer posted or a description change follows an issued receipt.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (GiftValuationUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
book_fair_value_cents | integer (int64) or null | No | Integer cents. Internal only; never printed or totalled. |
valuation_method | string or null | No | |
nonfinancial_category | string or null | No | |
property_description | string | No | What the receipt prints. Cannot change once a receipt was issued. |
Example request
{
"book_fair_value_cents": 42000,
"valuation_method": "independent appraisal"
}Responses
200The gift with its new valuation.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d23",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"contact_name": "Cedar Ridge Community Foundation",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"fund_name": "General fund",
"campaign_id": null,
"campaign_name": null,
"appeal_id": null,
"appeal_name": null,
"grant_id": null,
"kind": "in_kind",
"status": "posted",
"amount_cents": null,
"fair_market_value_cents": 0,
"fee_covered_cents": 0,
"received_on": "2026-11-04",
"contribution_date": "2026-11-04",
"payment_method": "in_kind",
"external_ref": null,
"in_kind_description": "40 cases canned vegetables, delivered to the pantry",
"property_description": "40 cases of canned vegetables",
"book_fair_value_cents": 35000,
"valuation_method": "comparable retail prices",
"nonfinancial_category": "food",
"pledge_id": null,
"tribute_type": null,
"tribute_name": null,
"notify_contact_id": null,
"notes": null,
"receipt_required": true,
"donation_form_id": null,
"donation_form_title": null,
"recurring_donation_id": null,
"recurring_interval": null,
"batch_id": null,
"batch_name": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-11-04T18:00:00Z",
"updated_at": "2026-11-04T18:00:00Z"
}Gift adjustments
List gift adjustments
GET /v1/gift-adjustments
Sorted by effective_on descending, then id descending.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
gift_id | string (uuid) | No | |
kind | one of: refund, void, reallocate, correct_amount, release_restriction | No | |
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
Responses
200A page of adjustments.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"kind": "reallocate",
"amount_cents": 5000,
"from_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"to_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"effective_on": "2026-10-05",
"reason": "Donor asked that the gift be unrestricted.",
"exception_reason": null,
"consent_reference": "Donor email of 2026-10-03, filed in the donor record",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}
],
"next_cursor": null
}Record a gift adjustment
POST /v1/gift-adjustmentsIdempotency-Key required
Adjustments are append-only too. Requires Idempotency-Key. A void or full refund also updates the gift's status. A correct_amount that would take a gift in an open deposit batch to $0.00 is 422 on /amount_cents (void it instead; see GiftAdjustmentCreate). Emits gift_adjustment.created.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (GiftAdjustmentCreate)
| Name | Type | Required | Description |
|---|---|---|---|
gift_id | string (uuid) | Yes | |
kind | one of: refund, void, reallocate, correct_amount, release_restriction | Yes | Mirrors the check constraint on gift_adjustments.kind. |
amount_cents | integer (int64) | Yes | Integer cents, signed. |
from_fund_id | string (uuid) or null | No | |
to_fund_id | string (uuid) or null | No | |
effective_on | string (date) | Yes | |
reason | string | Yes | |
exception_reason | string or null | No | release_restriction only: why this release may take the fund below zero. |
consent_reference | string or null | No | reallocate only: required when moving money out of a restricted fund. |
Example request
{
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"kind": "reallocate",
"amount_cents": 5000,
"to_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"effective_on": "2026-10-05",
"reason": "Donor asked that the gift be unrestricted.",
"consent_reference": "Donor email of 2026-10-03, filed in the donor record"
}Responses
201The recorded adjustment.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"kind": "reallocate",
"amount_cents": 5000,
"from_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"to_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"effective_on": "2026-10-05",
"reason": "Donor asked that the gift be unrestricted.",
"exception_reason": null,
"consent_reference": "Donor email of 2026-10-03, filed in the donor record",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}Get a gift adjustment
GET /v1/gift-adjustments/{id}
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The adjustment.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"kind": "reallocate",
"amount_cents": 5000,
"from_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"to_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"effective_on": "2026-10-05",
"reason": "Donor asked that the gift be unrestricted.",
"exception_reason": null,
"consent_reference": "Donor email of 2026-10-03, filed in the donor record",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}Gift batches
Check batches (ADR-019). A batch is one bank deposit of checks and cash: staff enter each gift into an open batch, and the batch closes only when its count and total equal the deposit ticket's; then its deposit slip goes to the bank. The count and total are the batch's gifts net of the voids and amount corrections recorded while it is open, frozen when it closes (pending CPA confirmation, question 26). Reopening a closed batch is admin only, with a reason, and audited. Online gifts never join a batch: their deposits are Stripe payouts.
List deposit batches
GET /v1/gift-batches
Sorted by deposit_date descending, then id descending. status lists open or closed batches; date_from and date_to bound the deposit date. Each batch carries its entered count and total and the difference from the expected ones. Soft-deleted batches are excluded unless include_deleted=true (admin only).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
status | one of: open, closed | No | |
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
Responses
200A page of batches.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"entered_count": 2,
"entered_total_cents": 15000,
"count_difference": 1,
"total_difference_cents": 7500,
"balanced": false,
"status": "open",
"notes": "Plate and envelopes from the 9:30 and 11:00 services.",
"closed_at": null,
"closed_by": null,
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:06:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Start a deposit batch
POST /v1/gift-batchesIdempotency-Key required
Starts an open batch with the deposit's expected count and total (write access). expected_total_cents below expected_count can never balance and is 422. Requires Idempotency-Key, so a retried request never starts a second batch for one deposit. Emits gift_batch.created.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (GiftBatchCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Leading and trailing spaces are dropped. |
deposit_date | string (date) | Yes | |
expected_count | integer | Yes | |
expected_total_cents | integer (int64) | Yes | Integer cents |
notes | string or null | No |
Example request
{
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"notes": "Plate and envelopes from the 9:30 and 11:00 services."
}Responses
201The new batch, open and empty.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"entered_count": 0,
"entered_total_cents": 0,
"count_difference": 3,
"total_difference_cents": 22500,
"balanced": false,
"status": "open",
"notes": "Plate and envelopes from the 9:30 and 11:00 services.",
"closed_at": null,
"closed_by": null,
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a deposit batch
GET /v1/gift-batches/{id}
The batch with its expected and entered figures and the difference. A soft-deleted batch is still returned, with deleted_at set, so undo screens can show it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The batch.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"entered_count": 2,
"entered_total_cents": 15000,
"count_difference": 1,
"total_difference_cents": 7500,
"balanced": false,
"status": "open",
"notes": "Plate and envelopes from the 9:30 and 11:00 services.",
"closed_at": null,
"closed_by": null,
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:06:00Z",
"deleted_at": null,
"deleted_by": null
}Update an open deposit batch
PATCH /v1/gift-batches/{id}
Changes the name, the deposit date, the expected count and total, or the notes while the batch is open (write access). 409 once it is closed (an admin reopens it first) or while it is deleted. Gifts already in it keep their dates (gifts are append-only), so the deposit date never moves before a gift the batch counts was received: that is 409, its detail saying how many gifts were received after the new date (a gift voided while the batch was open does not count). Emits gift_batch.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (GiftBatchUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | |
deposit_date | string (date) | No | |
expected_count | integer | No | |
expected_total_cents | integer (int64) | No | Integer cents |
notes | string or null | No |
Example request
{
"expected_total_cents": 22500
}Responses
200The updated batch.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"entered_count": 2,
"entered_total_cents": 15000,
"count_difference": 1,
"total_difference_cents": 7500,
"balanced": false,
"status": "open",
"notes": "Plate and envelopes from the 9:30 and 11:00 services.",
"closed_at": null,
"closed_by": null,
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:06:00Z",
"deleted_at": null,
"deleted_by": null
}Delete an empty deposit batch
DELETE /v1/gift-batches/{id}
Soft delete (ADR-005), only while no gift counts in the batch (every gift entered was voided while it was open, or none was entered): a batch in which a gift counts is 409, because gifts are append-only and keep their batch. Hides it from lists and keeps it readable by id; restore with POST /v1/gift-batches/{id}/restore. Write access. Emits gift_batch.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted deposit batch
POST /v1/gift-batches/{id}/restore
Clears deleted_at and deleted_by; the batch is open again. 409 if it is not deleted. Write access. Emits gift_batch.updated (ADR-005).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored batch, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d72",
"name": "Bake sale cash",
"deposit_date": "2026-10-12",
"expected_count": 1,
"expected_total_cents": 4000,
"entered_count": 0,
"entered_total_cents": 0,
"count_difference": 1,
"total_difference_cents": 4000,
"balanced": false,
"status": "open",
"notes": null,
"closed_at": null,
"closed_by": null,
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:12:00Z",
"deleted_at": null,
"deleted_by": null
}Close a deposit batch that balances
POST /v1/gift-batches/{id}/close
Closes an open batch when its entered count and total equal the expected ones (balanced). Otherwise 409 https://docs.operationcharity.org/problems/gift-batch-not-balanced: the detail says what is entered against what is expected and the difference, carried also by the extension members count_difference and total_difference_cents (expected minus entered). A closed batch takes no gifts and does not change until an admin reopens it; Postgres refuses the same, and the batch row is locked while it is counted, so no gift slips in after the count. Already closed, or deleted: 409 conflict. Write access. Emits gift_batch.closed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The closed batch.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409The batch doesn't balance yet: type `https://docs.operationcharity.org/problems/gift-batch-not-balanced`, whose detail says what is entered against what is expected and the difference, which the extension members `count_difference` and `total_difference_cents` (integer cents) also carry, expected minus entered (ADR-019). Or the batch is already closed, or deleted (type `conflict`). Nothing was changed.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"entered_count": 3,
"entered_total_cents": 22500,
"count_difference": 0,
"total_difference_cents": 0,
"balanced": true,
"status": "closed",
"notes": "Plate and envelopes from the 9:30 and 11:00 services.",
"closed_at": "2026-10-12T16:20:00Z",
"closed_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:20:00Z",
"deleted_at": null,
"deleted_by": null
}Reopen a closed deposit batch
POST /v1/gift-batches/{id}/reopen
Admin only: a signed-in admin (an API key gets 403). The reason is kept on the batch (reopen_reason) and written to the audit log with who reopened it and when. The batch is open again: it takes gifts and edits, and closes again only when it balances. Voids and corrections recorded while it was closed still do not count. 409 when it is not closed. Emits gift_batch.reopened.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (GiftBatchReopen)
| Name | Type | Required | Description |
|---|---|---|---|
reason | string | Yes | Why the batch is reopened. Kept on the batch ( |
Example request
{
"reason": "The bank's deposit receipt shows four checks; one was left out of the batch."
}Responses
200The reopened batch.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"name": "Sunday collection, Oct 12",
"deposit_date": "2026-10-12",
"expected_count": 3,
"expected_total_cents": 22500,
"entered_count": 3,
"entered_total_cents": 22500,
"count_difference": 0,
"total_difference_cents": 0,
"balanced": true,
"status": "open",
"notes": "Plate and envelopes from the 9:30 and 11:00 services.",
"closed_at": null,
"closed_by": null,
"reopened_at": "2026-10-13T15:00:00Z",
"reopened_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"reopen_reason": "The bank's deposit receipt shows four checks; one was left out of the batch.",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-13T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}List the gifts in a deposit batch
GET /v1/gift-batches/{id}/entries
Sorted by created_at ascending, then id ascending: the order the gifts were entered. Every gift entered into the batch, each as the batch counts it (GiftBatchEntry): voided entries stay in the list with counted: false. A deleted batch's entries are still listed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
Responses
200A page of entries.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d73",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"received_on": "2026-10-12",
"payment_method": "check",
"check_number": "1001",
"status": "posted",
"entered_cents": 10000,
"counted_cents": 10000,
"counted": true,
"created_at": "2026-10-12T16:05:00Z"
},
{
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d74",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"received_on": "2026-10-12",
"payment_method": "check",
"check_number": "1001",
"status": "voided",
"entered_cents": 10000,
"counted_cents": 0,
"counted": false,
"created_at": "2026-10-12T16:06:00Z"
}
],
"next_cursor": null
}Download a closed batch's deposit slip
GET /v1/gift-batches/{id}/deposit-slip
The deposit slip as a PDF, drawn with the receipts' PDF tooling (ADR-009): the organization's name and address, the batch's name and deposit date, each counted check in the order entered (its number, the donor and the amount the batch counts), cash on its own line, and the total. Entries voided while the batch was open are left out; the total equals the batch's. 409 while the batch is open: close it first, so the slip lists a balanced deposit. Read access. Rate-limited to 30 requests per minute per caller, in its own bucket. Content-Disposition: attachment.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The deposit slip.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"%PDF-1.7 …"Soft credits
List soft credits
GET /v1/soft-credits
Sorted by created_at descending, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
gift_id | string (uuid) | No | |
contact_id | string (uuid) | No |
Responses
200A page of soft credits.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"contact_name": "Kenji Nakamura",
"amount_cents": 5000,
"role": "household_member",
"reason": null,
"created_at": "2026-10-04T22:16:00Z",
"updated_at": "2026-10-04T22:16:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a soft credit
POST /v1/soft-credits
Credits a contact for influencing a gift without changing who gave it. Never counted in receipts or balances. Emits soft_credit.created.
Request body (SoftCreditCreate)
| Name | Type | Required | Description |
|---|---|---|---|
gift_id | string (uuid) | Yes | |
contact_id | string (uuid) | Yes | |
amount_cents | integer (int64) | Yes | Integer cents |
role | one of: household_member, daf_advisor, matched_donor, solicitor, influencer, honoree, memorial, board_or_foundation_officer, peer_to_peer_fundraiser, organization_affiliation | Yes | Why a contact is credited for someone else's gift (ADR-008). Soft credits never count in receipts, totals, balances or retention. |
reason | string or null | No |
Example request
{
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"amount_cents": 5000,
"role": "household_member"
}Responses
201The new soft credit.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"contact_name": "Kenji Nakamura",
"amount_cents": 5000,
"role": "household_member",
"reason": null,
"created_at": "2026-10-04T22:16:00Z",
"updated_at": "2026-10-04T22:16:00Z",
"deleted_at": null,
"deleted_by": null
}Get a soft credit
GET /v1/soft-credits/{id}
A soft-deleted soft credit is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The soft credit.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"contact_name": "Kenji Nakamura",
"amount_cents": 5000,
"role": "household_member",
"reason": null,
"created_at": "2026-10-04T22:16:00Z",
"updated_at": "2026-10-04T22:16:00Z",
"deleted_at": null,
"deleted_by": null
}Update a soft credit
PATCH /v1/soft-credits/{id}
Emits soft_credit.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (SoftCreditUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) | No | |
amount_cents | integer (int64) | No | Integer cents |
role | one of: household_member, daf_advisor, matched_donor, solicitor, influencer, honoree, memorial, board_or_foundation_officer, peer_to_peer_fundraiser, organization_affiliation | No | Why a contact is credited for someone else's gift (ADR-008). Soft credits never count in receipts, totals, balances or retention. |
reason | string or null | No |
Example request
{
"role": "household_member",
"reason": "Spouse; gave from the joint account."
}Responses
200The updated soft credit.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"contact_name": "Kenji Nakamura",
"amount_cents": 5000,
"role": "household_member",
"reason": null,
"created_at": "2026-10-04T22:16:00Z",
"updated_at": "2026-10-04T22:16:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a soft credit
DELETE /v1/soft-credits/{id}
Soft delete (ADR-005): sets deleted_at; a deleted soft credit no longer counts toward the contact's totals until restored. Emits soft_credit.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted soft credit
POST /v1/soft-credits/{id}/restore
Clears deleted_at and deleted_by. 409 if the soft credit is not deleted. Emits soft_credit.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored soft credit, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"contact_name": "Kenji Nakamura",
"amount_cents": 5000,
"role": "household_member",
"reason": null,
"created_at": "2026-10-04T22:16:00Z",
"updated_at": "2026-10-04T22:16:00Z",
"deleted_at": null,
"deleted_by": null
}Grants
List grants
GET /v1/grants
Sorted by report_due_on ascending (nulls last), then name, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
status | one of: prospect, applied, awarded, declined, closed | No | |
funder_contact_id | string (uuid) | No | |
fund_id | string (uuid) | No |
Responses
200A page of grants.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"awarded_cents": 2500000,
"indirect_rate_bps": 1500,
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"allowed_uses": "Shelter staffing, utilities and bedding. No capital purchases.",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"notes": "Second payment on receipt of the interim report.",
"created_at": "2026-09-20T16:00:00Z",
"updated_at": "2026-10-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a grant
POST /v1/grants
Record grant payments as gifts of kind grant_payment with grant_id set. Emits grant.created.
Request body (GrantCreate)
| Name | Type | Required | Description |
|---|---|---|---|
funder_contact_id | string (uuid) or null | No | |
fund_id | string (uuid) or null | No | Fund that receives the grant payments. |
name | string | Yes | |
status | one of: prospect, applied, awarded, declined, closed | No | |
awarded_cents | integer (int64) | No | Integer cents |
indirect_rate_bps | integer | No | Indirect cost rate in basis points of modified total direct costs. 1500 = the 15% de minimis rate (2 CFR 200.414(f)). |
period_start | string (date) or null | No | |
period_end | string (date) or null | No | |
allowed_uses | string or null | No | |
report_due_on | string (date) or null | No | |
next_payment_due_on | string (date) or null | No | |
notes | string or null | No |
Example request
{
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"awarded_cents": 2500000,
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"allowed_uses": "Shelter staffing, utilities and bedding. No capital purchases.",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15"
}Responses
201The new grant.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"awarded_cents": 2500000,
"indirect_rate_bps": 1500,
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"allowed_uses": "Shelter staffing, utilities and bedding. No capital purchases.",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"notes": "Second payment on receipt of the interim report.",
"created_at": "2026-09-20T16:00:00Z",
"updated_at": "2026-10-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a grant
GET /v1/grants/{id}
A soft-deleted grant is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The grant.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"awarded_cents": 2500000,
"indirect_rate_bps": 1500,
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"allowed_uses": "Shelter staffing, utilities and bedding. No capital purchases.",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"notes": "Second payment on receipt of the interim report.",
"created_at": "2026-09-20T16:00:00Z",
"updated_at": "2026-10-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a grant
PATCH /v1/grants/{id}
Emits grant.updated. Moving a grant to awarded creates the grant-report task.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (GrantUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
funder_contact_id | string (uuid) or null | No | |
fund_id | string (uuid) or null | No | Fund that receives the grant payments. |
name | string | No | |
status | one of: prospect, applied, awarded, declined, closed | No | |
awarded_cents | integer (int64) | No | Integer cents |
indirect_rate_bps | integer | No | Indirect cost rate in basis points of modified total direct costs. 1500 = the 15% de minimis rate (2 CFR 200.414(f)). |
period_start | string (date) or null | No | |
period_end | string (date) or null | No | |
allowed_uses | string or null | No | |
report_due_on | string (date) or null | No | |
next_payment_due_on | string (date) or null | No | |
notes | string or null | No |
Example request
{
"status": "awarded",
"awarded_cents": 2500000
}Responses
200The updated grant.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"awarded_cents": 2500000,
"indirect_rate_bps": 1500,
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"allowed_uses": "Shelter staffing, utilities and bedding. No capital purchases.",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"notes": "Second payment on receipt of the interim report.",
"created_at": "2026-09-20T16:00:00Z",
"updated_at": "2026-10-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a grant
DELETE /v1/grants/{id}
Soft delete (ADR-005): sets deleted_at and hides the grant from lists; restore with POST /v1/grants/{id}/restore. Still refused (409) once the grant has payments; set status to closed or declined instead. Emits grant.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Get a grant's position
GET /v1/grants/{id}/position
Award, period, received (payments to date net of refunds), receivable, direct and indirect costs, remaining and deadlines for one grant as of a date (B6). The same row, computed by the same code, as the grant's line on GET /v1/reports/grant-status. A soft-deleted grant still answers, so undo screens can show it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
as_of | string (date) | No | Report date. Defaults to today in the organization's timezone. |
Responses
200The grant's position.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"grant_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"funder_name": "Cedar Ridge Community Foundation",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"awarded_cents": 2500000,
"received_cents": 1250000,
"receivable_cents": 1250000,
"spent_cents": 0,
"indirect_rate_bps": 1500,
"indirect_cents": 0,
"remaining_cents": 2500000,
"unspent_cash_cents": 1250000,
"days_to_period_end": 177,
"days_to_report_due": 207,
"overspent": false,
"deadlines": [
{
"kind": "next_payment_due",
"due_on": "2027-01-15",
"days_until": 102,
"overdue": false,
"reported_on": null
},
{
"kind": "period_end",
"due_on": "2027-03-31",
"days_until": 177,
"overdue": false,
"reported_on": null
},
{
"kind": "report_due",
"due_on": "2027-04-30",
"days_until": 207,
"overdue": false,
"reported_on": null
}
],
"next_deadline": {
"kind": "next_payment_due",
"due_on": "2027-01-15",
"days_until": 102,
"overdue": false,
"reported_on": null
},
"overdue": false
}Restore a deleted grant
POST /v1/grants/{id}/restore
Clears deleted_at and deleted_by. 409 if the grant is not deleted. Emits grant.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored grant, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"awarded_cents": 2500000,
"indirect_rate_bps": 1500,
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"allowed_uses": "Shelter staffing, utilities and bedding. No capital purchases.",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"notes": "Second payment on receipt of the interim report.",
"created_at": "2026-09-20T16:00:00Z",
"updated_at": "2026-10-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Receipts
List receipts
GET /v1/receipts
Sorted by created_at descending, then id.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
contact_id | string (uuid) | No | |
gift_id | string (uuid) | No | |
kind | one of: gift, year_end | No | |
tax_year | integer | No |
Responses
200A page of receipts.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "gift",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"tax_year": null,
"contribution_date": "2026-10-04",
"total_cents": 5000,
"deductible_cents": 5000,
"goods_services_statement": "No goods or services were provided in exchange for this contribution.",
"rendered_text": "Maple Street Food Bank\n1200 Maple Street\nDenver, CO 80202\nEIN 84-1234567\n\nContribution receipt\n\nReceipt number: 3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16\nDate issued: October 4, 2026\n\nDonor\nAiko Nakamura\n\nContribution\nContribution date: October 4, 2026\nAmount: $50.00\nDesignated to: General fund (unrestricted)\nDeductible amount: $50.00\n\nNo goods or services were provided in exchange for this contribution.\n\nThis receipt is your written acknowledgment of this contribution for federal income tax purposes. Please keep it with your tax records.\n",
"supersedes_receipt_id": null,
"superseded_at": null,
"reissue_reason": null,
"sent_at": "2026-10-04T22:20:00Z",
"sent_via": "email",
"created_at": "2026-10-04T22:20:00Z",
"updated_at": "2026-10-04T22:20:00Z"
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d17",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "year_end",
"gift_id": null,
"tax_year": 2026,
"contribution_date": null,
"total_cents": 65000,
"deductible_cents": 62500,
"goods_services_statement": "In exchange for these contributions you received goods or services with an estimated fair market value of $25.00. The amount of your contributions that is deductible for federal income tax purposes is limited to $625.00.",
"rendered_text": "Maple Street Food Bank\nEIN 84-1234567\n\n2026 giving statement\n\nReceipt number: 3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d17\nDate issued: January 10, 2027\n\nDonor\nAiko Nakamura\n\nContributions\nDate Designation Amount\nMarch 1, 2026 General fund (unrestricted) $350.00\nJune 15, 2026 Winter Shelter Fund (purpose-restricted) $300.00\n\nTotal contributions: $650.00\nLess goods or services provided: $25.00\nTotal deductible: $625.00\n",
"supersedes_receipt_id": null,
"superseded_at": null,
"reissue_reason": null,
"sent_at": null,
"sent_via": null,
"created_at": "2027-01-10T16:00:00Z",
"updated_at": "2027-01-10T16:00:00Z"
}
],
"next_cursor": null
}Issue a receipt for a gift
POST /v1/receiptsIdempotency-Key required
Renders the IRS Publication 1771 acknowledgment for one posted gift using the money library (deductible amount, goods-and-services statement, designation) and freezes it: the text, HTML and a snapshot of every input are stored on the receipt and never re-rendered from live data (ADR-009). In-kind gifts print the property description and never a value. A gift has one live receipt; issuing twice returns 409. With reissue: true and a reason, the live receipt is superseded (kept, superseded_at set, audited) and a new one is issued that points at it. The organization's EIN and mailing address are required: without them the answer is 422 https://docs.operationcharity.org/problems/receipt-settings-incomplete with pointers /organization/ein and /organization/mailing_address (set them with PATCH /v1/organization). With send_via: email the receipt is also queued to be emailed to the donor in the same transaction (ADR-014), when email is set up on this deployment; otherwise it is issued and not queued (send it later with POST /v1/receipts/{id}/send). Requires Idempotency-Key. Emits receipt.issued.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (ReceiptIssue)
| Name | Type | Required | Description |
|---|---|---|---|
gift_id | string (uuid) | Yes | |
send_via | one of: email, null | No |
|
reissue | boolean | No | Omit (false) to issue. True supersedes the gift's live receipt with a new rendering (both are kept). Requires |
reason | string | No | Why the receipt is reissued. Printed on the new receipt and audited. |
Example request
{
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"send_via": "email"
}Responses
201The issued receipt.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "gift",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"tax_year": null,
"contribution_date": "2026-10-04",
"total_cents": 5000,
"deductible_cents": 5000,
"goods_services_statement": "No goods or services were provided in exchange for this contribution.",
"rendered_text": "Maple Street Food Bank\n1200 Maple Street\nDenver, CO 80202\nEIN 84-1234567\n\nContribution receipt\n\nReceipt number: 3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16\nDate issued: October 4, 2026\n\nDonor\nAiko Nakamura\n\nContribution\nContribution date: October 4, 2026\nAmount: $50.00\nDesignated to: General fund (unrestricted)\nDeductible amount: $50.00\n\nNo goods or services were provided in exchange for this contribution.\n\nThis receipt is your written acknowledgment of this contribution for federal income tax purposes. Please keep it with your tax records.\n",
"supersedes_receipt_id": null,
"superseded_at": null,
"reissue_reason": null,
"sent_at": "2026-10-04T22:20:00Z",
"sent_via": "email",
"created_at": "2026-10-04T22:20:00Z",
"updated_at": "2026-10-04T22:20:00Z"
}Preview a gift receipt
POST /v1/receipts/preview
Renders the receipt a gift would get, with the same rules and settings as issuing, and stores nothing. The web app shows it before issuing. Read access is enough.
Request body (ReceiptPreviewRequest)
| Name | Type | Required | Description |
|---|---|---|---|
gift_id | string (uuid) | Yes | |
reissue | boolean | No | Preview a reissue of the gift's live receipt (409 when it has none). Requires |
reason | string | No | Printed on the reissued receipt, as in |
Example request
{
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13"
}Responses
200What issuing would print.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"kind": "gift",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"tax_year": null,
"total_cents": 5000,
"deductible_cents": 5000,
"goods_services_statement": "No goods or services were provided in exchange for this contribution.",
"rendered_text": "Maple Street Food Bank\nEIN 84-1234567\n\nContribution receipt\n\nReceipt number: (assigned at issue)\nDate issued: October 5, 2026\n…",
"existing_receipt_id": null,
"missing_settings": [],
"warnings": []
}Issue year-end statements in a batch
POST /v1/receipts/year-endIdempotency-Key required
One statement per donor summing posted gifts in the calendar year net of refunds (pledges excluded, pledge payments included), rendered and frozen like a gift receipt (ADR-009): per-gift table with designation, totals, goods and services, and the $250 acknowledgment. Without contact_ids the batch takes everyone and answers 422 above 1,000 donors; POST /v1/receipts/year-end/preview lists the donor ids to page with. With reissue: true the listed donors' live statements are superseded. Like a gift receipt, it needs the organization's EIN and mailing address (422 receipt-settings-incomplete). With send_via: email each statement is also queued to be emailed to its donor (ADR-014), when email is set up on this deployment. Requires Idempotency-Key. Emits receipt.issued once per statement.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (YearEndBatch)
| Name | Type | Required | Description |
|---|---|---|---|
tax_year | integer | Yes | |
contact_ids | list of string (uuid) | No | Restrict the batch to these donors. Omit for everyone with a posted gift in the year (max 1000 per call; page with contact_ids). |
send_via | one of: email, null | No |
|
reissue | boolean | No | Omit (false) to issue. True supersedes these donors' live statements for the year with new renderings. Requires |
reason | string | No | Why the statements are reissued. Printed and audited. |
Example request
{
"tax_year": 2026
}Responses
200Statements issued and donors skipped.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"tax_year": 2026,
"issued": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d17",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "year_end",
"gift_id": null,
"tax_year": 2026,
"contribution_date": null,
"total_cents": 65000,
"deductible_cents": 62500,
"goods_services_statement": "In exchange for these contributions you received goods or services with an estimated fair market value of $25.00. The amount of your contributions that is deductible for federal income tax purposes is limited to $625.00.",
"rendered_text": "Maple Street Food Bank\nEIN 84-1234567\n\n2026 giving statement\n\nReceipt number: 3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d17\nDate issued: January 10, 2027\n\nDonor\nAiko Nakamura\n\nContributions\nDate Designation Amount\nMarch 1, 2026 General fund (unrestricted) $350.00\nJune 15, 2026 Winter Shelter Fund (purpose-restricted) $300.00\n\nTotal contributions: $650.00\nLess goods or services provided: $25.00\nTotal deductible: $625.00\n",
"supersedes_receipt_id": null,
"superseded_at": null,
"reissue_reason": null,
"sent_at": null,
"sent_via": null,
"created_at": "2027-01-10T16:00:00Z",
"updated_at": "2027-01-10T16:00:00Z"
}
],
"skipped": [
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"reason": "no_gifts"
}
]
}Preview a year-end batch
POST /v1/receipts/year-end/preview
Counts and totals of the statements a batch would issue for the tax year, and the donor ids to page through it (contact_ids, sorted) in slices of batch_cap. Stores nothing. Read access is enough.
Request body (YearEndPreviewRequest)
| Name | Type | Required | Description |
|---|---|---|---|
tax_year | integer | Yes | |
contact_ids | list of string (uuid) | No | Preview only these donors. Omit for everyone with a gift contributed in the year (no cap: the preview pages nothing). |
Example request
{
"tax_year": 2026
}Responses
200What the batch would do.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"tax_year": 2026,
"donor_count": 3,
"statement_count": 1,
"already_issued_count": 1,
"do_not_contact_count": 0,
"no_gifts_count": 1,
"total_cents": 65000,
"deductible_cents": 62500,
"contact_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
],
"batch_cap": 1000,
"missing_settings": [],
"warnings": []
}Get a receipt
GET /v1/receipts/{id}
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The receipt.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "gift",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"tax_year": null,
"contribution_date": "2026-10-04",
"total_cents": 5000,
"deductible_cents": 5000,
"goods_services_statement": "No goods or services were provided in exchange for this contribution.",
"rendered_text": "Maple Street Food Bank\n1200 Maple Street\nDenver, CO 80202\nEIN 84-1234567\n\nContribution receipt\n\nReceipt number: 3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16\nDate issued: October 4, 2026\n\nDonor\nAiko Nakamura\n\nContribution\nContribution date: October 4, 2026\nAmount: $50.00\nDesignated to: General fund (unrestricted)\nDeductible amount: $50.00\n\nNo goods or services were provided in exchange for this contribution.\n\nThis receipt is your written acknowledgment of this contribution for federal income tax purposes. Please keep it with your tax records.\n",
"supersedes_receipt_id": null,
"superseded_at": null,
"reissue_reason": null,
"sent_at": "2026-10-04T22:20:00Z",
"sent_via": "email",
"created_at": "2026-10-04T22:20:00Z",
"updated_at": "2026-10-04T22:20:00Z"
}Get a download link for the receipt PDF
GET /v1/receipts/{id}/pdf
Returns a signed download path valid for 15 minutes; a superseded receipt answers 409 receipt-superseded. The token is an HMAC over the receipt id, the organization and the expiry (secret RECEIPT_URL_SECRET), so the link works without a credential and only for this receipt until it expires. The PDF is drawn from the document frozen at issue time (ADR-009), so it never changes after issue. 409 for a receipt issued before PDFs existed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200A signed download link.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"url": "/v1/receipts/3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16/pdf/download?token=eyJyIjoiM2Y5ZCJ9.4d2e9c1f",
"expires_at": "2026-10-05T14:45:00Z"
}Download the receipt PDF with a signed link
GET /v1/receipts/{id}/pdf/downloadNo key needed
Unauthenticated by design: the token from GET /v1/receipts/{id}/pdf is the credential. It is scoped to this receipt and its organization and expires after 15 minutes; a wrong, altered or expired token is 403, and a receipt superseded since the link was signed is 409 receipt-superseded. Rate-limited to 30 requests per minute per client IP. The response is the PDF with Content-Disposition: attachment and Cache-Control: private, no-store.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
token | string | Yes |
Responses
200The receipt PDF.400Malformed request (bad cursor, unknown filter, unparseable body).403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"%PDF-1.7 …"Email a receipt to its donor
POST /v1/receipts/{id}/send
Queues the receipt to be emailed to its donor, also when it was emailed before (a resend). The email carries the receipt exactly as it was issued, with its PDF (ADR-009, ADR-014). When Postmark accepts it, the receipt's sent_at and sent_via are set, receipt.sent is emitted and the donor's timeline gets the email. 409 for a superseded receipt, a receipt issued before PDFs existed, or one already queued. Also 409 https://docs.operationcharity.org/problems/not-emailed when the donor is deleted, marked do-not-contact, has no email address or is on the suppression list: the attempt is still recorded, as a suppressed email with the reason, so the receipt's email history shows why. 503 while sending isn't set up on this deployment.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
202The queued email; it is sent within about a minute.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Email isn't set up on this deployment for what was asked (sending needs the Postmark server token, the platform sending domain and APP_ORIGIN; organizations' own sending domains need the Postmark account token), or Postmark could not be reached. Nothing was sent or changed; try again later (ADR-014).
Example response (202)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d65",
"kind": "receipt",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"receipt_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"welcome_enrollment_id": null,
"welcome_step": null,
"stream": "transactional",
"from_address": "\"Maple Street Food Bank\" <giving@maplestreetfoodbank.org>",
"reply_to": "office@maplestreetfoodbank.org",
"to_address": "aiko.nakamura@example.com",
"subject": "Your receipt from Maple Street Food Bank",
"text_body": "Maple Street Food Bank\n…",
"html_body": "<!doctype html>…",
"attach_receipt_pdf": true,
"status": "queued",
"attempt": 0,
"next_attempt_at": "2026-10-12T15:00:00Z",
"postmark_message_id": null,
"error": null,
"sent_at": null,
"bodies_erased_at": null,
"email_send_id": null,
"delivered_at": null,
"unsubscribed_at": null,
"complained_at": null,
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z"
}Communications
List communications
GET /v1/communications
Sorted by occurred_at descending, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
contact_id | string (uuid) | No | |
channel | one of: email, letter, call, text, meeting, note | No | |
direction | one of: inbound, outbound, internal | No | |
occurred_from | string (date-time) | No | |
occurred_to | string (date-time) | No |
Responses
200A page of communications.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "email",
"direction": "outbound",
"subject": "Thank you for keeping the shelter warm",
"body_text": "Aiko, your gift of $50.00 to the Winter Shelter Fund means two more nights of warm beds. Thank you.",
"body_html": null,
"message_id": "<thanks-6d13@maplestreetfoodbank.org>",
"thread_id": null,
"from_address": "luis@maplestreetfoodbank.org",
"to_addresses": [
"aiko.nakamura@example.com"
],
"occurred_at": "2026-10-05T15:00:00Z",
"source": "manual",
"logged_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"inbound_email_id": null,
"email_message_id": null,
"attachment_count": null,
"created_at": "2026-10-05T15:00:00Z",
"updated_at": "2026-10-05T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Log a communication
POST /v1/communications
Logs an email, letter, call, text, meeting or note on the contact's timeline. A duplicate message_id returns 409. Emits communication.created.
Request body (CommunicationCreate)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) | Yes | |
channel | one of: email, letter, call, text, meeting, note | Yes | |
direction | one of: inbound, outbound, internal | Yes | |
subject | string or null | No | |
body_text | string or null | No | |
body_html | string or null | No | |
message_id | string or null | No | Email Message-ID, unique per organization; used for threading and dedupe. |
thread_id | string or null | No | |
from_address | string or null | No | |
to_addresses | list of string or null | No | |
occurred_at | string (date-time) | No | |
source | string or null | No | postmark_inbound, mailbox_sync, manual or system. |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "call",
"direction": "outbound",
"subject": "Thank-you call",
"body_text": "Thanked Aiko for the shelter gift; she wants the spring newsletter.",
"occurred_at": "2026-10-05T15:00:00Z"
}Responses
201The logged communication.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "email",
"direction": "outbound",
"subject": "Thank you for keeping the shelter warm",
"body_text": "Aiko, your gift of $50.00 to the Winter Shelter Fund means two more nights of warm beds. Thank you.",
"body_html": null,
"message_id": "<thanks-6d13@maplestreetfoodbank.org>",
"thread_id": null,
"from_address": "luis@maplestreetfoodbank.org",
"to_addresses": [
"aiko.nakamura@example.com"
],
"occurred_at": "2026-10-05T15:00:00Z",
"source": "manual",
"logged_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"inbound_email_id": null,
"email_message_id": null,
"attachment_count": null,
"created_at": "2026-10-05T15:00:00Z",
"updated_at": "2026-10-05T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a communication
GET /v1/communications/{id}
A soft-deleted communication is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The communication.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "email",
"direction": "outbound",
"subject": "Thank you for keeping the shelter warm",
"body_text": "Aiko, your gift of $50.00 to the Winter Shelter Fund means two more nights of warm beds. Thank you.",
"body_html": null,
"message_id": "<thanks-6d13@maplestreetfoodbank.org>",
"thread_id": null,
"from_address": "luis@maplestreetfoodbank.org",
"to_addresses": [
"aiko.nakamura@example.com"
],
"occurred_at": "2026-10-05T15:00:00Z",
"source": "manual",
"logged_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"inbound_email_id": null,
"email_message_id": null,
"attachment_count": null,
"created_at": "2026-10-05T15:00:00Z",
"updated_at": "2026-10-05T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a communication
PATCH /v1/communications/{id}
Only manually logged communications can be edited; captured email returns 409. Emits communication.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (CommunicationUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
subject | string or null | No | |
body_text | string or null | No | |
body_html | string or null | No | |
occurred_at | string (date-time) | No | |
channel | one of: email, letter, call, text, meeting, note | No | |
direction | one of: inbound, outbound, internal | No |
Example request
{
"subject": "Thank you for keeping the shelter warm"
}Responses
200The updated communication.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "email",
"direction": "outbound",
"subject": "Thank you for keeping the shelter warm",
"body_text": "Aiko, your gift of $50.00 to the Winter Shelter Fund means two more nights of warm beds. Thank you.",
"body_html": null,
"message_id": "<thanks-6d13@maplestreetfoodbank.org>",
"thread_id": null,
"from_address": "luis@maplestreetfoodbank.org",
"to_addresses": [
"aiko.nakamura@example.com"
],
"occurred_at": "2026-10-05T15:00:00Z",
"source": "manual",
"logged_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"inbound_email_id": null,
"email_message_id": null,
"attachment_count": null,
"created_at": "2026-10-05T15:00:00Z",
"updated_at": "2026-10-05T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a communication
DELETE /v1/communications/{id}
Soft delete (ADR-005): sets deleted_at; restore with POST /v1/communications/{id}/restore. Emits communication.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted communication
POST /v1/communications/{id}/restore
Clears deleted_at and deleted_by. 409 if the communication is not deleted. Emits communication.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored communication, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d18",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"channel": "email",
"direction": "outbound",
"subject": "Thank you for keeping the shelter warm",
"body_text": "Aiko, your gift of $50.00 to the Winter Shelter Fund means two more nights of warm beds. Thank you.",
"body_html": null,
"message_id": "<thanks-6d13@maplestreetfoodbank.org>",
"thread_id": null,
"from_address": "luis@maplestreetfoodbank.org",
"to_addresses": [
"aiko.nakamura@example.com"
],
"occurred_at": "2026-10-05T15:00:00Z",
"source": "manual",
"logged_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"inbound_email_id": null,
"email_message_id": null,
"attachment_count": null,
"created_at": "2026-10-05T15:00:00Z",
"updated_at": "2026-10-05T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Tasks
List tasks
GET /v1/tasks
The daily action queue. Sorted by due_on ascending (nulls last), then created_at, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
status | one of: open, done, dismissed | No | |
assigned_to | string (uuid) | No | |
contact_id | string (uuid) | No | |
grant_id | string (uuid) | No | |
kind | string | No | |
due_before | string (date) | No | Inclusive. |
due_after | string (date) | No | Inclusive. |
Responses
200A page of tasks.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "open",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": null,
"completed_at": null,
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a task
POST /v1/tasks
Emits task.created.
Request body (TaskCreate)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) or null | No | |
grant_id | string (uuid) or null | No | |
kind | string | No | |
title | string | Yes | |
due_on | string (date) or null | No | |
assigned_to | string (uuid) or null | No |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"kind": "custom",
"title": "Invite Aiko to the shelter open house",
"due_on": "2026-10-20",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03"
}Responses
201The new task.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "open",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": null,
"completed_at": null,
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z",
"deleted_at": null,
"deleted_by": null
}Get a task
GET /v1/tasks/{id}
A soft-deleted task is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The task.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "open",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": null,
"completed_at": null,
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z",
"deleted_at": null,
"deleted_by": null
}Update a task
PATCH /v1/tasks/{id}
Complete, dismiss, reassign or reschedule. Emits task.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (TaskUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
title | string | No | |
due_on | string (date) or null | No | |
status | one of: open, done, dismissed | No | |
assigned_to | string (uuid) or null | No |
Example request
{
"status": "done"
}Responses
200The updated task.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "done",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": null,
"completed_at": "2026-10-05T15:05:00Z",
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-05T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a task
DELETE /v1/tasks/{id}
Soft delete (ADR-005): sets deleted_at. A deleted generated task keeps its dedupe key, so the action queue leaves its item out rather than recreating it (ADR-010); prefer status: dismissed, which reads as intended in the timeline. Emits task.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted task
POST /v1/tasks/{id}/restore
Clears deleted_at and deleted_by. 409 if the task is not deleted. Emits task.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored task, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "open",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": null,
"completed_at": null,
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-04T22:15:00Z",
"deleted_at": null,
"deleted_by": null
}Snooze a task
POST /v1/tasks/{id}/snooze
Hides an open task from the action queue until until (B5, ADR-010); the queue shows it again on that date. until: null clears the snooze, which is the undo. 409 when the task is done, dismissed or deleted. Emits task.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (TaskSnooze)
| Name | Type | Required | Description |
|---|---|---|---|
until | string (date) or null | Yes |
Example request
{
"until": "2026-10-15"
}Responses
200The task with `snoozed_until` set or cleared.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"grant_id": null,
"kind": "thank_you",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"due_on": "2026-10-06",
"status": "open",
"assigned_to": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"generated": true,
"snoozed_until": "2026-10-15",
"completed_at": null,
"created_at": "2026-10-04T22:15:00Z",
"updated_at": "2026-10-08T15:05:00Z",
"deleted_at": null,
"deleted_by": null
}Get the daily action queue
GET /v1/action-queue
The home screen: thank-yous due, first-time donors ready for a second ask, lapsing donors and grant reports due, each with a count. Every item is backed by a task (ADR-010), so PATCH /v1/tasks/{id} (done, dismissed) and POST /v1/tasks/{id}/snooze act on it and the change persists.
- Thank-yous: every open thank-you task (created with each gift), overdue first.
- Second asks: donors whose only gift (posted, not a pledge; in-kind counts) is 30 to 90 days old.
- Lapsing: donors who gave last fiscal year, nothing yet this year, and whose last gift is 10 or more months old.
- Grant reports: awarded grants whose
report_due_onis within 30 days or past.
Reading the queue creates the tasks it needs, once (dedupe keys second_ask:<contact_id>:<fiscal year of the first gift>, lapsing:<contact_id>:<fiscal year>, grant_report:<grant_id>:<report_due_on>), and emits task.created for each. Second-ask and lapsing tasks stay in the queue while open; reading the queue marks them done (emitting task.updated) once the donor gives again; an open second-ask task past the 90-day window with no second gift is dismissed and its title gains "(window passed)"; one whose only gift was fully refunded gains "(gift refunded)". Done, dismissed, deleted and snoozed tasks are left out. Do-not-contact donors get no second-ask or lapsing items. Amounts count the donor's own gifts only, never soft credits.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
date | string (date) | No | The day to build the queue for. Defaults to today in the organization's timezone. Only today's queue creates or closes tasks; other dates are read-only previews built from the tasks that already exist. |
per_section | integer | No | Most items returned per section; |
Responses
200The queue.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"date": "2026-10-08",
"fiscal_year": 2027,
"generated_at": "2026-10-08T15:00:00Z",
"counts": {
"thank_yous": 1,
"second_asks": 1,
"lapsing": 1,
"grant_reports": 1
},
"truncated": {
"thank_yous": false,
"second_asks": false,
"lapsing": false,
"grant_reports": false
},
"thank_yous": [
{
"kind": "thank_you",
"task_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19",
"title": "Thank Aiko Nakamura for her $50.00 gift",
"reason": "Gave $50.00 on Oct 4, 2026. The thank-you was due Oct 6, 2026.",
"due_on": "2026-10-06",
"overdue": true,
"contact": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"display_name": "Aiko Nakamura",
"do_not_contact": false
},
"gift": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"kind": "one_time",
"amount_cents": 5000,
"received_on": "2026-10-04"
},
"grant": null,
"suggested_amount_cents": null
}
],
"second_asks": [
{
"kind": "second_ask",
"task_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d41",
"title": "Ask Ben Okafor for a second gift",
"reason": "First gave $100.00 on Aug 1, 2026, 68 days ago, and has not given again.",
"due_on": "2026-10-30",
"overdue": false,
"contact": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"display_name": "Ben Okafor",
"do_not_contact": false
},
"gift": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d11",
"kind": "one_time",
"amount_cents": 10000,
"received_on": "2026-08-01"
},
"grant": null,
"suggested_amount_cents": 12500
}
],
"lapsing": [
{
"kind": "lapsing",
"task_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d42",
"title": "Reach out to Carmen Diaz before a year passes",
"reason": "Last gave $250.00 on Nov 1, 2025, 11 months ago, and nothing this fiscal year.",
"due_on": "2026-11-01",
"overdue": false,
"contact": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"display_name": "Carmen Diaz",
"do_not_contact": false
},
"gift": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"kind": "one_time",
"amount_cents": 25000,
"received_on": "2025-11-01"
},
"grant": null,
"suggested_amount_cents": 30000
}
],
"grant_reports": [
{
"kind": "grant_report",
"task_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d43",
"title": "Send the report for Youth meals 2026",
"reason": "Report due Oct 31, 2026, in 23 days.",
"due_on": "2026-10-31",
"overdue": false,
"contact": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"display_name": "Front Range Community Foundation",
"do_not_contact": false
},
"gift": null,
"grant": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d15",
"name": "Youth meals 2026",
"status": "awarded",
"awarded_cents": 2500000,
"report_due_on": "2026-10-31"
},
"suggested_amount_cents": null
}
]
}Put a lapsed donor in the action queue
POST /v1/action-queue/lapsing
The "Add to queue" action on the LYBUNT and SYBUNT reports (C5). Creates the donor's lapsing task for the current fiscal year with the same dedupe key the action queue uses (lapsing:<contact>:<fiscal year>, ADR-010), so the queue never shows the donor twice; 201 with created: true. When that task already exists and is open, it is returned as is (200); when it is done or dismissed it is opened again (200, reopened: true, task.updated). 404 for an unknown or deleted contact; 409 when the contact is do-not-contact, has no gift before this fiscal year, has already given this fiscal year, or the task was deleted (restore it instead). Emits task.created.
Request body (LapsingQueueRequest)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) | Yes |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06"
}Responses
200The task already existed; open now.201The task was created.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"task": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"grant_id": null,
"kind": "lapsing",
"title": "Reach out to Ben Nakamura before a year passes",
"due_on": "2026-12-18",
"status": "open",
"assigned_to": null,
"generated": true,
"snoozed_until": null,
"completed_at": null,
"created_at": "2026-10-05T14:00:00Z",
"updated_at": "2026-10-08T15:05:00Z",
"deleted_at": null,
"deleted_by": null
},
"created": false,
"reopened": true
}Imports
List import jobs
GET /v1/import-jobs
Sorted by created_at descending, then id.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
status | one of: uploaded, previewed, committed, rolled_back, failed, discarded | No |
Responses
200A page of import jobs.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "previewed",
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"row_count": 412,
"error_count": 3,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:05:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
}
],
"next_cursor": null
}Upload a file to import
POST /v1/import-jobs
Step 1 of 3. Stores the file's rows, detects columns and suggests a mapping from known vendor exports (Little Green Light, Bloomerang, DonorPerfect) or header names. Takes a CSV, a zip of CSVs, a vendor's multi-file export or an Excel workbook (.xlsx, one worksheet). Nothing is written to contacts or gifts yet. A file that cannot be read (an old .xls, a password-protected or macro-enabled workbook, more than 50,000 rows) is a 422 whose message says what to do.
Request body (ImportUpload)
| Name | Type | Required | Description |
|---|---|---|---|
file | string (binary) | No | CSV, up to 4.5 MB (the request limit on Vercel) and 50,000 rows. Text that was already decoded; a Windows-1252 or UTF-16 file arrives intact only through |
file_base64 | string | No | The file's bytes, base64-encoded: a CSV, a zip or an Excel workbook (.xlsx). A CSV's encoding is sniffed (UTF-8 with or without a BOM, UTF-16LE/BE, Windows-1252, and UTF-8 files with stray Windows-1252 bytes). Same limits as |
files | list of object | No | CSV, zip or Excel workbook (.xlsx) files, base64-encoded; a workbook must be the only file. Same 4.5 MB limit, measured on all the decoded bytes together; all the parts together may unpack to at most 256 files and 32 MB (a part that is not zipped counts as one file of its own size), checked from each zip's directory before anything is unpacked, and a part that takes the upload past either is refused with a 422 at its field. |
sheet | string | No | For an Excel workbook: the worksheet to read, by name (exactly, else ignoring case and outer spaces). Defaults to the first worksheet that is not hidden. A name the workbook does not have is a 422 at |
source_name | string | No | Defaults to the uploaded filename. |
Example request
{
"file": "donors-2025.csv (binary)",
"source_name": "donors-2025.csv"
}Responses
201The job, detected columns and a suggested mapping.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"job": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "uploaded",
"column_mapping": {},
"row_count": 412,
"error_count": 0,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:00:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
},
"detected_columns": [
"First Name",
"Last Name",
"Email",
"Amount",
"Date",
"Fund"
],
"sample_rows": [
[
"Aiko",
"Nakamura",
"aiko.nakamura@example.com",
"50.00",
"2025-12-18",
"Winter Shelter Fund"
]
],
"suggested_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"detected_vendor": null,
"detected_vendor_label": null,
"encoding": "utf-8",
"withheld_columns": []
}Get an import job
GET /v1/import-jobs/{id}
The job, with what its upload found while its rows are kept, so a client can show the column matching of an import that was uploaded and never previewed (E7). The file's first rows (sample_rows) and the suggested mapping go only to callers with write access (round 3); a read key or a read-only member gets them empty, and sees the job, its status and counts, its columns, the format recognized and the withheld columns.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The import job and what its upload found.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d21",
"source_name": "bloomerang-transactions.xlsx",
"status": "uploaded",
"column_mapping": {},
"row_count": 412,
"error_count": 0,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:00:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null,
"detected_columns": [
"Account Number",
"Name",
"Transaction Date",
"Transaction Amount",
"Transaction Type",
"Transaction Number"
],
"sample_rows": [
[
"2001",
"Theo Marchetti",
"2025-11-20",
"250",
"Donation",
"T-77"
]
],
"suggested_mapping": {
"Account Number": "contact.vendor_id",
"Name": "contact.full_name",
"Transaction Date": "gift.received_on",
"Transaction Amount": "gift.amount",
"Transaction Type": "gift.kind",
"Transaction Number": "gift.vendor_id"
},
"detected_vendor": "bloomerang",
"detected_vendor_label": "Bloomerang",
"withheld_columns": [],
"encoding": "utf-8",
"workbook": {
"sheet": "Transactions",
"sheets": [
"Transactions",
"Notes"
]
},
"bundle": null
}Preview an import with a column mapping
POST /v1/import-jobs/{id}/preview
Step 2 of 3. Saves the mapping, parses every row, matches existing contacts and earlier rows (exact email, then phone, then fuzzy name + postal code), proposes a resolution per row and reports issues. Can be repeated with a different mapping until commit. Moves the job to previewed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (ImportPreviewRequest)
| Name | Type | Required | Description |
|---|---|---|---|
column_mapping | ColumnMapping | Yes | Source column header → target field. Targets: |
Example request
{
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
}
}Responses
200What the commit will do.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"job": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "previewed",
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"row_count": 412,
"error_count": 3,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:05:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
},
"summary": {
"rows": 412,
"new_contacts": 380,
"matched_contacts": 29,
"gifts": 409,
"errors": 3,
"duplicate_rows": 31,
"duplicates_to_review": 31,
"email_matches_to_review": 29,
"attached_rows": 0,
"already_imported": 0,
"problem_rows": 4
},
"rows": [
{
"row_number": 1,
"contact": {
"kind": "person",
"first_name": "Aiko",
"last_name": "Nakamura",
"email": "aiko.nakamura@example.com",
"source": "import",
"source_ref": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20"
},
"gift": {
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"kind": "one_time",
"amount_cents": 5000,
"received_on": "2025-12-18"
},
"matched_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"issues": [],
"partners": [],
"household_name": null,
"duplicates": [
{
"row": 1,
"existing_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"row_ref": null,
"tier": 1,
"score": 1,
"evidence": [
{
"field": "email",
"rule": "exact",
"similarity": 1
},
{
"field": "last_name",
"rule": "exact",
"similarity": 1
},
{
"field": "first_name",
"rule": "exact",
"similarity": 1
}
]
}
],
"needs_review": true,
"suggested_email_match": true,
"match_reviewed": false,
"household_hint": null,
"resolution": {
"row_number": 1,
"action": "attach_to_existing",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}
},
{
"row_number": 2,
"contact": {
"kind": "person",
"first_name": "Ben",
"last_name": "Nakamura",
"email": "ben.nakamura@example.com",
"source": "import",
"source_ref": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20"
},
"gift": null,
"matched_contact_id": null,
"issues": [
{
"field": "gift.received_on",
"message": "This date isn't valid; row will import the contact without a gift."
}
],
"partners": [],
"household_name": null,
"duplicates": [
{
"row": 2,
"existing_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"row_ref": null,
"tier": 3,
"score": 0.94,
"evidence": [
{
"field": "last_name",
"rule": "exact",
"similarity": 1
},
{
"field": "first_name",
"rule": "nickname",
"similarity": 0.9
},
{
"field": "postal_code",
"rule": "zip5",
"similarity": 1
}
]
}
],
"needs_review": true,
"resolution": {
"row_number": 2,
"action": "create_new"
}
}
],
"duplicates": [
{
"row": 2,
"existing_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"row_ref": null,
"tier": 3,
"score": 0.94,
"evidence": [
{
"field": "last_name",
"rule": "exact",
"similarity": 1
},
{
"field": "first_name",
"rule": "nickname",
"similarity": 0.9
},
{
"field": "postal_code",
"rule": "zip5",
"similarity": 1
}
]
}
],
"detected_vendor": null,
"detected_vendor_label": null,
"detected_columns": [
"First Name",
"Last Name",
"Email",
"Amount",
"Date",
"Fund"
],
"sample_rows": [
[
"Aiko",
"Nakamura",
"aiko.nakamura@example.com",
"50.00",
"2025-12-18",
"Winter Shelter Fund"
],
[
"Ben",
"Nakamura",
"ben.nakamura@example.com",
"25.00",
"12/18/25 ish",
"Winter Shelter Fund"
]
],
"withheld_columns": []
}Accept all suggested matches
POST /v1/import-jobs/{id}/accept-matches
Between step 2 and step 3. A match on the same email, with a contact you have or an earlier row of the file, is a suggestion a person reviews, never settled (ADR-007 amendment, 2026-10-14). This marks every such row not yet reviewed as reviewed, with the resolution the preview proposed (attach to that contact or row); it counts as a person confirming, so it needs write access and nothing else is implied. Rows the commit request gives their own resolution are not affected by it. It changes nothing else about a row: nothing is merged, no household is created or joined (a household_hint only suggests), and a contact's fields are left alone. Only a previewed import can be accepted (409); a new preview clears every review. Takes no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200How many suggested matches were accepted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"job": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "previewed",
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"row_count": 412,
"error_count": 3,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:05:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
},
"accepted": 29,
"email_matches_to_review": 0
}Undo accept-all
POST /v1/import-jobs/{id}/accept-matches/undo
One action that returns every suggested email match to unreviewed: the rows are listed to review again (filter=duplicates) and commit is refused again until a person reviews them. It does not restore earlier per-row choices (accept-all changed none; the resolutions are as the preview proposed them, and choices sent in a commit request are not stored). Only a previewed import (409); undone is 0 when nothing was accepted. Takes no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200How many rows were put back.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"job": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "previewed",
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"row_count": 412,
"error_count": 3,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:05:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
},
"undone": 29,
"email_matches_to_review": 29
}Commit a previewed import
POST /v1/import-jobs/{id}/commitIdempotency-Key required
Step 3 of 3. Writes contacts, households and gifts in one transaction and records every created id for rollback. Takes an optional body of per-row resolutions for duplicate candidates (create_new, attach_to_existing with a contact or an earlier row, or skip); rows without one use the resolution the preview proposed. Records are never merged. An invalid resolution is a 422 with a pointer. Refused with 409, in plain words, while any row with a suggested email match is unreviewed (ADR-007 amendment, 2026-10-14; problem member code: email_matches_unreviewed with unreviewed_count; a preview made before the review step answers code: preview_outdated): accept the matches with acceptImportMatches, or give those rows a resolution here; nothing is imported when it is refused. Requires Idempotency-Key. Only a previewed job can be committed (409). Emits import_job.committed, then one contact.created or gift.created per record.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (ImportCommitRequest)
| Name | Type | Required | Description |
|---|---|---|---|
resolutions | list of ImportRowResolution | No | Overrides for the rows that need one; every other row uses the resolution its preview proposed. One entry per row. A row with a suggested email match must be reviewed: either accept the matches first (POST /v1/import-jobs/{id}/accept-matches) or give the row an entry here (any action, including the proposed one); otherwise commit is refused with 409. |
Example request
{
"resolutions": [
{
"row_number": 2,
"action": "attach_to_existing",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06"
},
{
"row_number": 7,
"action": "attach_to_existing",
"row_ref": 5
},
{
"row_number": 9,
"action": "skip"
},
{
"row_number": 12,
"action": "create_new"
}
]
}Responses
200The committed job.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "committed",
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"row_count": 412,
"error_count": 3,
"created_ids": {
"contacts": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d31"
],
"gifts": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d32"
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:10:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
}Roll back a committed import
POST /v1/import-jobs/{id}/rollback
Removes the contacts and households the import created and voids its gifts with a void adjustment (gifts are never deleted). Refused (409) if an imported gift already has a receipt; if an imported contact has since received a gift, a receipt or a soft credit, or an imported household has members the import did not create (ADR-007); or while an online payment can still arrive for an imported contact: a recurring donation that has not ended, or a donation form payment from the contact's email address in progress (ADR-013). Also 409 when the import changed while it was being undone (its commit landed between the read that chose the contacts to lock and the import's own lock): nothing changed, so try again. Emits import_job.rolled_back.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The rolled-back job.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20",
"source_name": "donors-2025.csv",
"status": "rolled_back",
"column_mapping": {
"First Name": "contact.first_name",
"Last Name": "contact.last_name",
"Email": "contact.email",
"Amount": "gift.amount",
"Date": "gift.received_on",
"Fund": "gift.fund"
},
"row_count": 412,
"error_count": 3,
"created_ids": {
"contacts": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d31"
],
"gifts": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d32"
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:20:00Z",
"discarded_at": null,
"discarded_by": null,
"discard_reason": null
}List a previewed import's rows
GET /v1/import-jobs/{id}/rows
Every row of a previewed import, or only its problems, its possible duplicates to review or its rows already imported, in the preview's row shape, so a client can show the rows past the preview's first 200 (E7). Sorted by row number (a keyset on it: rows are numbered once, at upload). problems is every row with an issue, rows that won't be imported included, except rows already imported (the preview's summary.problem_rows counts them); duplicates every row a person must choose for (needs_review, email matches included; summary.duplicates_to_review counts them), not the rows settled by the old system's donor ID; already_imported every row an earlier import brought in (summary.already_imported). Only a previewed import has rows to list: an uploaded one answers 409 (preview it first), and so does one imported, undone or discarded, whose rows are cleared. Write access (round 3): the rows are the organization's file, so only those who may import read them; a read key or a read-only member gets 403.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
filter | one of: problems, duplicates, already_imported, all | No | Which rows: |
limit | integer | No | Page size, 1–200; 200 by default, the preview's own page (most lists default to 50). |
cursor | string | No | Opaque cursor from a previous response's |
Responses
200A page of rows.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"row_number": 201,
"contact": {
"kind": "person",
"source": "import",
"source_ref": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20"
},
"gift": null,
"matched_contact_id": null,
"issues": [
{
"field": "contact",
"message": "The row has no name and no email, so no contact can be created. This row won't be imported; fix it in the file and upload it again to include it."
}
],
"partners": [],
"household_name": null,
"duplicates": [],
"needs_review": false,
"resolution": {
"row_number": 201,
"action": "skip"
},
"vendor_ids": null,
"already_imported": false,
"soft_credits": []
},
{
"row_number": 214,
"contact": {
"kind": "person",
"first_name": "Ben",
"last_name": "Nakamura",
"email": "ben.nakamura@example.com",
"source": "import",
"source_ref": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d20"
},
"gift": null,
"matched_contact_id": null,
"issues": [
{
"field": "gift.received_on",
"message": "This date isn't valid; row will import the contact without a gift."
}
],
"partners": [],
"household_name": null,
"duplicates": [],
"needs_review": false,
"resolution": {
"row_number": 214,
"action": "create_new"
},
"vendor_ids": null,
"already_imported": false,
"soft_credits": []
}
],
"next_cursor": "eyJ2IjpbIjIxNCJdLCJleHAiOjE3OTE0NjcyMDAwMDB9"
}Discard an import that was never imported
POST /v1/import-jobs/{id}/discard
For an import that was uploaded or previewed (or failed) and never imported (E7, ADR-007): clears every row's cells, preview and error at once and the import's column_mapping (round 3: a file without its header row has a donor's details for column names), and marks the import discarded, with discarded_at, discarded_by and discard_reason: requested. Its rows are only a copy of the organization's file: to bring them back, upload the file again (one of ADR-005's exceptions to undo). Recorded in the audit log without any of the rows. Emits import_job.discarded with the import's id, status, reason and time only (E7 fix round). Refused (409) once the import has been imported, undone or discarded. The daily expiry discards the same way, as expired, an import never imported 30 days after its last change.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The discarded import.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d21",
"source_name": "bloomerang-transactions.xlsx",
"status": "discarded",
"column_mapping": {},
"row_count": 412,
"error_count": 0,
"created_ids": {},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T13:00:00Z",
"updated_at": "2026-10-05T13:30:00Z",
"discarded_at": "2026-10-05T13:30:00Z",
"discarded_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"discard_reason": "requested"
}API keys
List API keys
GET /v1/api-keys
Admin only. Sorted by created_at descending, then id. Revoked keys are hidden unless include_revoked=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_revoked | boolean | No |
Responses
200A page of API keys.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d21",
"name": "Zapier integration",
"key_prefix": "oc_live_",
"scopes": [
"read",
"write"
],
"last_used_at": "2026-10-05T14:00:00Z",
"revoked_at": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-05T14:00:00Z"
}
],
"next_cursor": null
}Create an API key
POST /v1/api-keys
Admin only, session auth only (a key cannot mint keys). The secret is returned once; store it now.
Request body (ApiKeyCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | |
scopes | list of one of: read, write | No |
Example request
{
"name": "Zapier integration",
"scopes": [
"read",
"write"
]
}Responses
201The key, including its secret.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d21",
"name": "Zapier integration",
"key_prefix": "oc_live_",
"scopes": [
"read",
"write"
],
"last_used_at": null,
"revoked_at": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"secret": "oc_live_8Jf2kLq9xV3mN7pR1sT5uW0yZ4aB6cD8eF2gH4jK6m"
}Get an API key
GET /v1/api-keys/{id}
Admin only. Never returns the secret.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The key.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d21",
"name": "Zapier integration",
"key_prefix": "oc_live_",
"scopes": [
"read",
"write"
],
"last_used_at": "2026-10-05T14:00:00Z",
"revoked_at": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-05T14:00:00Z"
}Revoke an API key
POST /v1/api-keys/{id}/revoke
Admin only. Takes effect immediately and cannot be undone; the row is kept for the audit trail.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The revoked key.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d21",
"name": "Zapier integration",
"key_prefix": "oc_live_",
"scopes": [
"read",
"write"
],
"last_used_at": "2026-10-05T14:00:00Z",
"revoked_at": "2026-10-05T14:30:00Z",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z"
}Webhooks
Webhook subscriptions and API keys are configuration, not data resources; changes to them are audited but emit no webhook events.
List webhook subscriptions
GET /v1/webhook-subscriptions
Admin only. Sorted by created_at, then id. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
active | boolean | No |
Responses
200A page of subscriptions.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
],
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a webhook subscription
POST /v1/webhook-subscriptions
Admin only. The URL must be HTTPS. The signing secret is returned once here and again on rotation.
Request body (WebhookSubscriptionCreate)
| Name | Type | Required | Description |
|---|---|---|---|
url | string (uri) | Yes | |
events | list of one of: organization.updated, user.created, user.updated, contact.created, contact.updated, contact.deleted, tag.created, tag.updated, tag.deleted, custom_field_definition.created, custom_field_definition.updated, custom_field_definition.deleted, saved_list.created, saved_list.updated, saved_list.deleted, saved_list_snapshot.created, household.created, household.updated, household.deleted, contact_relationship.created, contact_relationship.updated, contact_relationship.deleted, fund.created, fund.updated, fund.deleted, campaign.created, campaign.updated, campaign.deleted, appeal.created, appeal.updated, appeal.deleted, soft_credit.created, soft_credit.updated, soft_credit.deleted, grant.created, grant.updated, grant.deleted, communication.created, communication.updated, communication.deleted, task.created, task.updated, task.deleted, gift.created, gift.updated, gift_adjustment.created, gift_batch.created, gift_batch.updated, gift_batch.deleted, gift_batch.closed, gift_batch.reopened, receipt.issued, receipt.sent, import_job.committed, import_job.rolled_back, import_job.discarded, donation_form.created, donation_form.updated, donation_form.deleted, recurring_donation.created, recurring_donation.updated, welcome_enrollment.created, welcome_enrollment.updated, email_template.created, email_template.updated, email_template.deleted, email_send.created, email_send.updated, email_send.deleted, webhook.test | Yes | |
active | boolean | No |
Example request
{
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
]
}Responses
201The subscription with its secret.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
],
"active": true,
"secret": "whsec_Qm9vdGxlZ2dpbmcgaXMgbm90IGFsbG93ZWQgaGVyZQ",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a webhook subscription
GET /v1/webhook-subscriptions/{id}
A soft-deleted webhook subscription is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The subscription.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
],
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a webhook subscription
PATCH /v1/webhook-subscriptions/{id}
Change the URL, the event list or pause it with active: false.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (WebhookSubscriptionUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
url | string (uri) | No | |
events | list of one of: organization.updated, user.created, user.updated, contact.created, contact.updated, contact.deleted, tag.created, tag.updated, tag.deleted, custom_field_definition.created, custom_field_definition.updated, custom_field_definition.deleted, saved_list.created, saved_list.updated, saved_list.deleted, saved_list_snapshot.created, household.created, household.updated, household.deleted, contact_relationship.created, contact_relationship.updated, contact_relationship.deleted, fund.created, fund.updated, fund.deleted, campaign.created, campaign.updated, campaign.deleted, appeal.created, appeal.updated, appeal.deleted, soft_credit.created, soft_credit.updated, soft_credit.deleted, grant.created, grant.updated, grant.deleted, communication.created, communication.updated, communication.deleted, task.created, task.updated, task.deleted, gift.created, gift.updated, gift_adjustment.created, gift_batch.created, gift_batch.updated, gift_batch.deleted, gift_batch.closed, gift_batch.reopened, receipt.issued, receipt.sent, import_job.committed, import_job.rolled_back, import_job.discarded, donation_form.created, donation_form.updated, donation_form.deleted, recurring_donation.created, recurring_donation.updated, welcome_enrollment.created, welcome_enrollment.updated, email_template.created, email_template.updated, email_template.deleted, email_send.created, email_send.updated, email_send.deleted, webhook.test | No | |
active | boolean | No |
Example request
{
"active": false
}Responses
200The updated subscription.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
],
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a webhook subscription
DELETE /v1/webhook-subscriptions/{id}
Soft delete (ADR-005): sets deleted_at and stops deliveries; pending deliveries are dropped. Restore with POST /v1/webhook-subscriptions/{id}/restore (deliveries resume from that point).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted webhook subscription
POST /v1/webhook-subscriptions/{id}/restore
Clears deleted_at so the subscription receives events again. 409 if it is not deleted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored webhook subscription, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
],
"active": true,
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-09-01T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Send a test delivery
POST /v1/webhook-subscriptions/{id}/test
Queues a webhook.test event to this subscription through the normal signed, retried delivery path, even while the subscription is paused. The delivery is sent within about a minute; follow it with delivery_id. 409 if the subscription is deleted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
202The test event was queued.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (202)
{
"event_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e39",
"type": "webhook.test",
"delivery_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d42"
}Rotate the signing secret
POST /v1/webhook-subscriptions/{id}/rotate-secret
The old secret keeps signing deliveries for 24 hours (both signatures are sent as v1= entries) so consumers can switch without missing events. 409 if the subscription is deleted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The subscription with its new secret.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"url": "https://hooks.example.com/operation-charity",
"events": [
"gift.created",
"receipt.issued",
"contact.updated"
],
"active": true,
"secret": "whsec_TmV3IHNlY3JldCBhZnRlciByb3RhdGlvbiAyMDI2",
"created_at": "2026-09-01T16:00:00Z",
"updated_at": "2026-10-05T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}List a subscription's deliveries
GET /v1/webhook-subscriptions/{id}/deliveries
Admin only. One row per event sent to this subscription, with its attempts and the last response. Sorted by created_at descending, then id. Also lists deliveries of a deleted subscription.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
status | one of: pending, delivered, failed | No | |
event_type | one of: organization.updated, user.created, user.updated, contact.created, contact.updated, contact.deleted, tag.created, tag.updated, tag.deleted, custom_field_definition.created, custom_field_definition.updated, custom_field_definition.deleted, saved_list.created, saved_list.updated, saved_list.deleted, saved_list_snapshot.created, household.created, household.updated, household.deleted, contact_relationship.created, contact_relationship.updated, contact_relationship.deleted, fund.created, fund.updated, fund.deleted, campaign.created, campaign.updated, campaign.deleted, appeal.created, appeal.updated, appeal.deleted, soft_credit.created, soft_credit.updated, soft_credit.deleted, grant.created, grant.updated, grant.deleted, communication.created, communication.updated, communication.deleted, task.created, task.updated, task.deleted, gift.created, gift.updated, gift_adjustment.created, gift_batch.created, gift_batch.updated, gift_batch.deleted, gift_batch.closed, gift_batch.reopened, receipt.issued, receipt.sent, import_job.committed, import_job.rolled_back, import_job.discarded, donation_form.created, donation_form.updated, donation_form.deleted, recurring_donation.created, recurring_donation.updated, welcome_enrollment.created, welcome_enrollment.updated, email_template.created, email_template.updated, email_template.deleted, email_send.created, email_send.updated, email_send.deleted, webhook.test | No |
Responses
200A page of deliveries.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d41",
"subscription_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"event_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e12",
"event_type": "gift.created",
"status": "pending",
"attempt": 2,
"next_attempt_at": "2026-10-05T14:36:00Z",
"response_status": 503,
"response_ms": 412,
"error": "HTTP 503.",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:31:00Z"
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d42",
"subscription_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"event_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e39",
"event_type": "webhook.test",
"status": "delivered",
"attempt": 1,
"next_attempt_at": null,
"response_status": 200,
"response_ms": 187,
"error": null,
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:31:00Z"
}
],
"next_cursor": null
}Retry a delivery
POST /v1/webhook-deliveries/{id}/retry
Admin only. Queues one more attempt of a delivered or failed delivery right away (a failed one that fails again goes straight back to failed). 409 while it is still pending, or when its subscription is deleted or paused.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The delivery, pending again.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d41",
"subscription_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d22",
"event_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e12",
"event_type": "gift.created",
"status": "pending",
"attempt": 2,
"next_attempt_at": "2026-10-05T14:36:00Z",
"response_status": 503,
"response_ms": 412,
"error": "HTTP 503.",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:31:00Z"
}Reports
Donor retention rate
GET /v1/reports/retention
Share of prior-fiscal-year donors who gave again (Fundraising Effectiveness Project definition) and the second-gift rate for first-time donors, plus a table of the last years years (donors, retained, rate, new and lapsed donors, money raised; C5). Hard credit only. Backed by retentionRate, secondGiftRate and retentionSeries in @operation-charity/money. Returns one object; not paginated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
fiscal_year | integer | Yes | Fiscal year, named for the calendar year in which it ends (uses the organization's |
basis | one of: fiscal, calendar | No |
|
years | integer | No | How many years the retention table covers, ending with |
Responses
200The retention figures.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"fiscal_year": 2026,
"basis": "fiscal",
"fiscal_year_start_month": 7,
"prior_year_donors": 240,
"retained": 108,
"rate": 0.45,
"second_gift": {
"first_time_donors": 90,
"retained": 21,
"rate": 0.2333
},
"years": [
{
"year": 2022,
"donors": 198,
"prior_year_donors": 185,
"retained": 80,
"rate": 0.4324,
"new_donors": 118,
"lapsed_donors": 105,
"raised_cents": 6120000
},
{
"year": 2023,
"donors": 215,
"prior_year_donors": 198,
"retained": 87,
"rate": 0.4394,
"new_donors": 128,
"lapsed_donors": 111,
"raised_cents": 6585000
},
{
"year": 2024,
"donors": 226,
"prior_year_donors": 215,
"retained": 95,
"rate": 0.4419,
"new_donors": 131,
"lapsed_donors": 120,
"raised_cents": 7010000
},
{
"year": 2025,
"donors": 240,
"prior_year_donors": 226,
"retained": 100,
"rate": 0.4425,
"new_donors": 140,
"lapsed_donors": 126,
"raised_cents": 7455000
},
{
"year": 2026,
"donors": 198,
"prior_year_donors": 240,
"retained": 108,
"rate": 0.45,
"new_donors": 90,
"lapsed_donors": 132,
"raised_cents": 5210000
}
]
}LYBUNT donors
GET /v1/reports/lybunt
LYBUNT (last year but unfortunately not this year): donors who gave last fiscal year but not this one, with lifetime giving, do-not-contact and their open lapsing task (C5). summary covers the whole list. Sorted by last_gift_cents descending, then contact_id. Backed by lybunt, suggestedAsk and lifetimeGiving.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
fiscal_year | integer | Yes | Fiscal year, named for the calendar year in which it ends (uses the organization's |
basis | one of: fiscal, calendar | No |
|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
Responses
200A page of lapsed donors.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"fiscal_year": 2026,
"basis": "fiscal",
"summary": {
"donor_count": 1,
"last_gifts_cents": 10000,
"lifetime_cents": 45000
},
"data": [
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
"display_name": "Ben Nakamura",
"last_gift_on": "2025-12-18",
"last_gift_cents": 10000,
"suggested_ask_cents": 12500,
"lifetime_cents": 45000,
"do_not_contact": false,
"queue_task_id": null
}
],
"next_cursor": null
}SYBUNT donors
GET /v1/reports/sybunt
SYBUNT (some year but unfortunately not this year): donors who gave in some earlier year but not last year or this one, with lifetime giving, do-not-contact and their open lapsing task (C5). summary covers the whole list. Sorted by last_gift_cents descending, then contact_id. Backed by sybunt, suggestedAsk and lifetimeGiving.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
fiscal_year | integer | Yes | Fiscal year, named for the calendar year in which it ends (uses the organization's |
basis | one of: fiscal, calendar | No |
|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
Responses
200A page of long-lapsed donors.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"fiscal_year": 2026,
"basis": "fiscal",
"summary": {
"donor_count": 1,
"last_gifts_cents": 100000,
"lifetime_cents": 350000
},
"data": [
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"display_name": "Cedar Ridge Community Foundation",
"last_gift_on": "2023-11-02",
"last_gift_cents": 100000,
"suggested_ask_cents": null,
"lifetime_cents": 350000,
"do_not_contact": false,
"queue_task_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d19"
}
],
"next_cursor": null
}Restricted balances per fund
GET /v1/reports/restricted-balances
Received, refunded (refunds, voids and amount corrections), reallocated in and out, released and remaining per fund as of a date, with the fund's purpose and time-restriction release date, ASC 958 totals and totals by restriction class. Hard credit only; soft credits never count. A fund whose releases include an allowed exception (ADR-008) is flagged negative_exception with the reasons. Backed by restrictedBalances and totalsByRestriction; GET /v1/funds/{id}/ledger lists the lines behind each fund's number. Returns every fund in one response; organizations have fewer than 500 funds, so this is not paginated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
as_of | string (date) | No | Report date. Defaults to today in the organization's timezone. |
Responses
200One row per fund.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"as_of": "2026-10-05",
"data": [
{
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"fund_name": "General fund",
"restriction": "unrestricted",
"purpose": null,
"restriction_release_date": null,
"received_cents": 1840000,
"refunded_cents": 5000,
"reallocated_in_cents": 5000,
"reallocated_out_cents": 0,
"released_cents": 0,
"remaining_cents": 1840000,
"release_due": false,
"negative_exception": false,
"negative_exception_reasons": []
},
{
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"restriction": "purpose",
"purpose": "Overnight shelter operations from November through March",
"restriction_release_date": null,
"received_cents": 1255000,
"refunded_cents": 0,
"reallocated_in_cents": 0,
"reallocated_out_cents": 5000,
"released_cents": 400000,
"remaining_cents": 850000,
"release_due": false,
"negative_exception": false,
"negative_exception_reasons": []
}
],
"totals": {
"with_restrictions_cents": 850000,
"without_restrictions_cents": 1840000,
"by_restriction": [
{
"restriction": "unrestricted",
"fund_count": 1,
"received_cents": 1840000,
"refunded_cents": 5000,
"reallocated_in_cents": 5000,
"reallocated_out_cents": 0,
"released_cents": 0,
"remaining_cents": 1840000
},
{
"restriction": "purpose",
"fund_count": 1,
"received_cents": 1255000,
"refunded_cents": 0,
"reallocated_in_cents": 0,
"reallocated_out_cents": 5000,
"released_cents": 400000,
"remaining_cents": 850000
},
{
"restriction": "time",
"fund_count": 0,
"received_cents": 0,
"refunded_cents": 0,
"reallocated_in_cents": 0,
"reallocated_out_cents": 0,
"released_cents": 0,
"remaining_cents": 0
},
{
"restriction": "perpetual",
"fund_count": 0,
"received_cents": 0,
"refunded_cents": 0,
"reallocated_in_cents": 0,
"reallocated_out_cents": 0,
"released_cents": 0,
"remaining_cents": 0
}
]
}
}Giving by fund
GET /v1/reports/giving-by-fund
Posted gifts grouped by fund over a date range (defaults to the current fiscal year), gross, refunds (refunds, voids and amount corrections effective in the range) and net, with each fund's share of the net total (C5). Hard credit only. Sorted by total_cents descending. Returns every fund with gifts in the range (under 500 rows); not paginated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
Responses
200One row per fund with gifts in the range.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"date_from": "2026-07-01",
"date_to": "2026-10-05",
"data": [
{
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"restriction": "purpose",
"gift_count": 212,
"donor_count": 180,
"total_cents": 1255000,
"gross_cents": 1260000,
"refunded_cents": 5000,
"share_bps": 6623
},
{
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"fund_name": "General fund",
"restriction": "unrestricted",
"gift_count": 96,
"donor_count": 71,
"total_cents": 640000,
"gross_cents": 640000,
"refunded_cents": 0,
"share_bps": 3377
}
],
"total_cents": 1895000,
"gross_cents": 1900000,
"refunded_cents": 5000
}Grant status
GET /v1/reports/grant-status
Awarded, received, receivable, direct and indirect costs, remaining (awarded − (direct + indirect)) and deadlines per grant as of a date, with funder, fund and period. Backed by grantPosition and grantDeadlines; GET /v1/grants/{id}/position returns the same row for one grant. Soft-deleted grants are left out. Sorted by days_to_report_due ascending (nulls last). Returns every grant matching the filter (under 1,000 rows); not paginated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
as_of | string (date) | No | Report date. Defaults to today in the organization's timezone. |
status | one of: prospect, applied, awarded, declined, closed | No |
Responses
200One row per grant.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"as_of": "2026-10-05",
"data": [
{
"grant_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d12",
"name": "Cedar Ridge winter shelter grant 2026",
"status": "awarded",
"funder_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"funder_name": "Cedar Ridge Community Foundation",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"period_start": "2026-11-01",
"period_end": "2027-03-31",
"report_due_on": "2027-04-30",
"next_payment_due_on": "2027-01-15",
"awarded_cents": 2500000,
"received_cents": 1250000,
"receivable_cents": 1250000,
"spent_cents": 0,
"indirect_rate_bps": 1500,
"indirect_cents": 0,
"remaining_cents": 2500000,
"unspent_cash_cents": 1250000,
"days_to_period_end": 177,
"days_to_report_due": 207,
"overspent": false,
"deadlines": [
{
"kind": "next_payment_due",
"due_on": "2027-01-15",
"days_until": 102,
"overdue": false,
"reported_on": null
},
{
"kind": "period_end",
"due_on": "2027-03-31",
"days_until": 177,
"overdue": false,
"reported_on": null
},
{
"kind": "report_due",
"due_on": "2027-04-30",
"days_until": 207,
"overdue": false,
"reported_on": null
}
],
"next_deadline": {
"kind": "next_payment_due",
"due_on": "2027-01-15",
"days_until": 102,
"overdue": false,
"reported_on": null
},
"overdue": false
}
]
}Export the retention table as CSV
GET /v1/reports/retention/export
Rate-limited to 30 requests per minute per caller, in its own bucket. The years table of GET /v1/reports/retention with the same parameters, one row per year, oldest first. Columns: Year, Basis, Donors, Donors the year before, Gave again, Retention rate (%) (one decimal, rounded half up but 0.0 only when no one gave again and 100.0 only when everyone did, so otherwise 0.1–99.9; empty with no prior-year donors), New donors, Lapsed donors, Raised (USD). Content-Disposition: attachment.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes | Export file format (C5). |
fiscal_year | integer | Yes | Fiscal year, named for the calendar year in which it ends (uses the organization's |
basis | one of: fiscal, calendar | No |
|
years | integer | No | How many years the retention table covers, ending with |
Responses
200The retention table.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"Year,Basis,Donors,Donors the year before,Gave again,Retention rate (%),New donors,Lapsed donors,Raised (USD)\r\n2025,fiscal,240,226,100,44.2,140,126,74550.00\r\n2026,fiscal,198,240,108,45.0,90,132,52100.00\r\n"Export LYBUNT donors as CSV
GET /v1/reports/lybunt/export
Rate-limited to 30 requests per minute per caller, in its own bucket. Every donor on GET /v1/reports/lybunt (gave last year but not this year) for the same parameters, in the same order, unpaginated. Columns: Contact ID, Name, Last gift date, Last gift (USD), Lifetime giving (USD), Suggested ask (USD) (empty when there is none), Do not contact (yes or no). Text cells that start with =, +, - or @ are prefixed with an apostrophe, and so is each part of a cell after a semicolon or a tab (the list separator in some locales) or after a line break (where a spreadsheet using that separator ends the row) that starts with one; a cell holding a semicolon, a tab or a line break is quoted. So a spreadsheet does not run them as formulas.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes | Export file format (C5). |
fiscal_year | integer | Yes | Fiscal year, named for the calendar year in which it ends (uses the organization's |
basis | one of: fiscal, calendar | No |
|
Responses
200The donors.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"Contact ID,Name,Last gift date,Last gift (USD),Lifetime giving (USD),Suggested ask (USD),Do not contact\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06,Ben Nakamura,2025-12-18,100.00,450.00,125.00,no\r\n"Export SYBUNT donors as CSV
GET /v1/reports/sybunt/export
Rate-limited to 30 requests per minute per caller, in its own bucket. Every donor on GET /v1/reports/sybunt (gave some earlier year but not last year or this year) for the same parameters, in the same order, unpaginated. Same columns as the LYBUNT export.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes | Export file format (C5). |
fiscal_year | integer | Yes | Fiscal year, named for the calendar year in which it ends (uses the organization's |
basis | one of: fiscal, calendar | No |
|
Responses
200The donors.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"Contact ID,Name,Last gift date,Last gift (USD),Lifetime giving (USD),Suggested ask (USD),Do not contact\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07,Cedar Ridge Community Foundation,2023-11-02,1000.00,3500.00,,no\r\n"Export giving by fund as CSV
GET /v1/reports/giving-by-fund/export
Rate-limited to 30 requests per minute per caller, in its own bucket. The rows of GET /v1/reports/giving-by-fund for the same range, then a Total row. Columns: Fund ID, Fund, Restriction, Gifts, Donors, Gross (USD), Refunds (USD), Net (USD), Share of total (%) (two decimals; empty when the total is not positive).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes | Export file format (C5). |
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
Responses
200One row per fund and a total.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"Fund ID,Fund,Restriction,Gifts,Donors,Gross (USD),Refunds (USD),Net (USD),Share of total (%)\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09,Winter Shelter Fund,Purpose restricted,212,180,12600.00,50.00,12550.00,66.23\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08,General fund,Unrestricted,96,71,6400.00,0.00,6400.00,33.77\r\n,Total,,308,,19000.00,50.00,18950.00,100.00\r\n"Export restricted balances as CSV
GET /v1/reports/restricted-balances/export
Rate-limited to 30 requests per minute per caller, in its own bucket. The rows of GET /v1/reports/restricted-balances for the same date, one per fund. Columns: Fund ID, Fund, Restriction, Purpose, Restriction ends, Received (USD), Refunded (USD), Moved in (USD), Moved out (USD), Released (USD), Remaining (USD), Release due (yes or no), Below zero by exception (yes or no).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes | Export file format (C5). |
as_of | string (date) | No | Report date. Defaults to today in the organization's timezone. |
Responses
200One row per fund.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"Fund ID,Fund,Restriction,Purpose,Restriction ends,Received (USD),Refunded (USD),Moved in (USD),Moved out (USD),Released (USD),Remaining (USD),Release due,Below zero by exception\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09,Winter Shelter Fund,Purpose restricted,Overnight shelter operations from November through March,,12550.00,0.00,0.00,50.00,4000.00,8500.00,no,no\r\n"Export grant status as CSV
GET /v1/reports/grant-status/export
Rate-limited to 30 requests per minute per caller, in its own bucket. The rows of GET /v1/reports/grant-status for the same date and status, in the same order. Columns: Grant ID, Grant, Status, Funder, Fund, Period start, Period end, Report due, Awarded (USD), Received (USD), Receivable (USD), Spent (USD), Indirect costs (USD), Remaining (USD), Days to report due, Overdue (yes or no).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
format | one of: csv | Yes | Export file format (C5). |
as_of | string (date) | No | Report date. Defaults to today in the organization's timezone. |
status | one of: prospect, applied, awarded, declined, closed | No |
Responses
200One row per grant.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
"Grant ID,Grant,Status,Funder,Fund,Period start,Period end,Report due,Awarded (USD),Received (USD),Receivable (USD),Spent (USD),Indirect costs (USD),Remaining (USD),Days to report due,Overdue\r\n3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14,Youth tutoring 2026,Awarded,Cedar Ridge Community Foundation,Youth program,2026-01-01,2026-12-31,2027-01-31,25000.00,12500.00,12500.00,0.00,0.00,25000.00,118,no\r\n"Audit log
List audit log entries
GET /v1/audit-log
Admin only. Every insert, update and delete on money-bearing tables, written by database triggers; there are no write operations. Sorted by at descending, then id descending.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
table_name | string | No | gifts, gift_adjustments or any audited table. |
row_id | string (uuid) | No | |
actor | string (uuid) | No | User id. |
date_from | string (date) | No | Inclusive lower bound on the record's business date. |
date_to | string (date) | No | Inclusive upper bound on the record's business date. |
Responses
200A page of audit entries.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": 48213,
"table_name": "gift_adjustments",
"row_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"action": "insert",
"actor": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"old_row": null,
"new_row": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d14",
"gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"kind": "reallocate",
"amount_cents": 5000,
"from_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"to_fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"effective_on": "2026-10-05",
"reason": "Donor asked that the gift be unrestricted.",
"exception_reason": null,
"consent_reference": "Donor email of 2026-10-03, filed in the donor record",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-05T14:30:00Z",
"updated_at": "2026-10-05T14:30:00Z"
},
"at": "2026-10-05T14:30:00Z"
},
{
"id": 48190,
"table_name": "gifts",
"row_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"action": "insert",
"actor": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"old_row": null,
"new_row": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"kind": "one_time",
"status": "posted",
"amount_cents": 5000,
"received_on": "2026-10-04"
},
"at": "2026-10-04T22:15:00Z"
}
],
"next_cursor": null
}Reference data
Global, read-only figures that are the same for every organization (IRS tax-year figures).
Get a tax year's IRS figures
GET /v1/tax-year-figures/{year}
The quid-pro-quo safe-harbor figures and thresholds the receipt rules use for one tax year (ADR-008). Read-only; seeded by migration from the IRS revenue procedure named in source. 404 for a year that is not seeded.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
year | integer | Yes | Tax year. |
Responses
200The figures.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"tax_year": 2026,
"low_cost_article_cents": 1390,
"token_payment_min_cents": 6950,
"insubstantial_benefit_cap_cents": 13900,
"insubstantial_benefit_pct_bps": 200,
"quid_pro_quo_threshold_cents": 7500,
"acknowledgment_threshold_cents": 25000,
"source": "Rev. Proc. 2025-32 (insubstantial benefit amounts); IRC 6115 ($75 quid pro quo disclosure); IRC 170(f)(8) ($250 acknowledgment)"
}Payments
The organization's own Stripe account (Stripe Connect, ADR-013). The organization is the merchant of record, owns refunds and disputes in its Stripe dashboard and is paid out to its own bank. Anyone can see its status; connecting it is admin only.
Get the organization's Stripe account
GET /v1/payment-account
Read access: anyone signed in, and read API keys, can see whether the organization's own Stripe account can take donations and what Stripe still needs (ADR-013); connecting it stays admin only. When an admin calls it while an account is connected, the flags are refreshed from Stripe (if Stripe cannot be reached, the last known state is returned), and if charges are no longer enabled every active donation form is turned off (donation_form.updated). Everyone else gets the stored state, which Stripe's account.updated webhook keeps current, so reading never changes anything for them. 503 while online giving isn't fully set up on this deployment.
Responses
200The payment account.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Online giving isn't fully set up on this deployment (the Stripe secret key, the webhook signing secret and APP_ORIGIN must all be set, so nothing half-configured can take money), or Stripe could not be reached or could not start the payment. Nothing was charged and nothing the donor typed was kept; try again later.
Example response (200)
{
"status": "active",
"stripe_account_id": "acct_1QAbCdEfGhIjKlMn",
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true,
"requirements_due": []
}Start or continue Stripe onboarding
POST /v1/payment-account/onboarding-link
Admin only. On first use, creates the organization's own Stripe account: the full Stripe dashboard, the organization pays Stripe's fees and owns refunds and disputes, Stripe collects what it needs (ADR-013); no platform fee is ever taken. Returns a single-use Stripe-hosted onboarding link. Stripe sends the admin back to /settings/payments?onboarding=return on this app, or /settings/payments?onboarding=refresh when the link has expired (ask for a new one). One Stripe account per organization; it cannot be switched. 409 with Stripe's reason when Stripe refuses (for example, Connect is not set up on the platform); 503 while online giving isn't fully set up on this deployment, or when Stripe cannot be reached.
Responses
200An onboarding link.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Online giving isn't fully set up on this deployment (the Stripe secret key, the webhook signing secret and APP_ORIGIN must all be set, so nothing half-configured can take money), or Stripe could not be reached or could not start the payment. Nothing was charged and nothing the donor typed was kept; try again later.
Example response (200)
{
"url": "https://connect.stripe.com/setup/s/acct_1QAbCdEfGhIjKlMn/Jh2fT7xYkQ0p",
"expires_at": "2026-10-05T14:35:00Z"
}Donation forms
Public, brandable donation forms at /give/<slug> (ADR-013), and the two unauthenticated operations the form page uses. Donors pay on Stripe Checkout; card and bank details never reach this API.
List donation forms
GET /v1/donation-forms
Sorted by title (case-insensitive), then id. active=true lists the forms taking donations. Soft-deleted records are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
active | boolean | No |
Responses
200A page of donation forms.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"allow_custom_amount": true,
"min_amount_cents": 500,
"max_amount_cents": 2500000,
"frequencies": [
"one_time",
"month"
],
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
],
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"thank_you_message": "Thank you for keeping the shelter warm.",
"allow_tributes": true,
"active": true,
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-02T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create a donation form
POST /v1/donation-forms
The form is public at /give/<slug> once it is active. A slug already used by any form is 409; active: true while online giving isn't fully set up on this deployment, without a Stripe account that can take charges, or without the EIN and mailing address, is 409 naming what is missing. Funds, presets and limits are checked as DonationFormCreate says (422). Emits donation_form.created.
Request body (DonationFormCreate)
| Name | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | |
title | string | Yes | |
intro | string or null | No | |
logo_url | string or null | No | Must start with https://. |
accent_color | string or null | No |
|
preset_amounts_cents | list of integer (int64) | Yes | Integer cents. 1–6 amounts, each between the minimum and the maximum. |
default_amount_cents | integer (int64) or null | No | Integer cents. Between the minimum and the maximum. |
allow_custom_amount | boolean | No | |
min_amount_cents | integer (int64) | No | Integer cents |
max_amount_cents | integer (int64) | No | Integer cents. Above min_amount_cents. |
frequencies | list of one of: one_time, month, year | Yes | |
fund_ids | list of string (uuid) | Yes | |
campaign_id | string (uuid) or null | No | |
appeal_id | string (uuid) or null | No | |
thank_you_message | string or null | No | |
allow_tributes | boolean | No | |
active | boolean | No |
Example request
{
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"frequencies": [
"one_time",
"month"
],
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
],
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"thank_you_message": "Thank you for keeping the shelter warm.",
"active": true
}Responses
201The new donation form.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"allow_custom_amount": true,
"min_amount_cents": 500,
"max_amount_cents": 2500000,
"frequencies": [
"one_time",
"month"
],
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
],
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"thank_you_message": "Thank you for keeping the shelter warm.",
"allow_tributes": true,
"active": true,
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-02T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a donation form
GET /v1/donation-forms/{id}
A soft-deleted form is still returned, with deleted_at set, so undo screens can show it. Lists hide it, and its public address answers 404.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The donation form.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"allow_custom_amount": true,
"min_amount_cents": 500,
"max_amount_cents": 2500000,
"frequencies": [
"one_time",
"month"
],
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
],
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"thank_you_message": "Thank you for keeping the shelter warm.",
"allow_tributes": true,
"active": true,
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-02T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Update a donation form
PATCH /v1/donation-forms/{id}
Same rules as create, checked on the form as it would be after the change. Turning a form on is 409 until online giving is fully set up on this deployment, the Stripe account can take charges and the EIN and mailing address are set; turning it off always works. Checkouts already started keep the amount, fund and frequency the donor chose. Emits donation_form.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (DonationFormUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
slug | string | No | Changing the slug changes the public address; links to the old one stop working. |
title | string | No | |
intro | string or null | No | |
logo_url | string or null | No | Must start with https://. |
accent_color | string or null | No |
|
preset_amounts_cents | list of integer (int64) | No | Integer cents |
default_amount_cents | integer (int64) or null | No | Integer cents |
allow_custom_amount | boolean | No | |
min_amount_cents | integer (int64) | No | Integer cents |
max_amount_cents | integer (int64) | No | Integer cents |
frequencies | list of one of: one_time, month, year | No | |
fund_ids | list of string (uuid) | No | |
campaign_id | string (uuid) or null | No | |
appeal_id | string (uuid) or null | No | |
thank_you_message | string or null | No | |
allow_tributes | boolean | No | |
active | boolean | No |
Example request
{
"active": false
}Responses
200The updated donation form.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"allow_custom_amount": true,
"min_amount_cents": 500,
"max_amount_cents": 2500000,
"frequencies": [
"one_time",
"month"
],
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
],
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"thank_you_message": "Thank you for keeping the shelter warm.",
"allow_tributes": true,
"active": true,
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-02T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a donation form
DELETE /v1/donation-forms/{id}
Soft delete (ADR-005): sets deleted_at, hides the form from lists and takes its public address offline (404); restore with POST /v1/donation-forms/{id}/restore. Gifts already made through it keep their donation_form_id. The slug stays taken until the form is restored or renamed. Emits donation_form.deleted with the row (deleted_at set).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted donation form
POST /v1/donation-forms/{id}/restore
Clears deleted_at and deleted_by. 409 if the form is not deleted; if a fund it offers, or its campaign or appeal, has been deleted since (ADR-013; the detail names them): restore them, or change the form so it no longer uses them, first; or if it is active and could not be turned on now (online giving isn't fully set up on this deployment, the Stripe account cannot take charges, or the EIN or mailing address is missing; the detail says which): turn it off first, or fix the setting. Emits donation_form.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored donation form, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"allow_custom_amount": true,
"min_amount_cents": 500,
"max_amount_cents": 2500000,
"frequencies": [
"one_time",
"month"
],
"fund_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
],
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"thank_you_message": "Thank you for keeping the shelter warm.",
"allow_tributes": true,
"active": true,
"created_at": "2026-10-01T16:00:00Z",
"updated_at": "2026-10-02T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a donation form's public page
GET /v1/public-donation-forms/{slug}No key needed
Unauthenticated: what the public form page at /give/<slug> renders, and nothing else. An unknown slug, an inactive form and a deleted form all answer the same 404. Rate-limited to 60 requests per minute per client IP.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | The form's public address, as in |
Responses
200The public form.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"slug": "winter-shelter",
"title": "Keep the shelter warm this winter",
"intro": "Every $35 gives one guest a warm bed, dinner and breakfast.",
"logo_url": "https://maplestreetfoodbank.org/logo.png",
"accent_color": "#1f6f4a",
"preset_amounts_cents": [
2500,
5000,
10000,
25000
],
"default_amount_cents": 5000,
"allow_custom_amount": true,
"min_amount_cents": 500,
"max_amount_cents": 2500000,
"frequencies": [
"one_time",
"month"
],
"funds": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"name": "Winter Shelter Fund"
},
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"name": "General fund"
}
],
"allow_tributes": true,
"thank_you_message": "Thank you for keeping the shelter warm.",
"organization_name": "Maple Street Food Bank"
}Start paying on Stripe Checkout
POST /v1/public-donation-forms/{slug}/checkout-sessionsNo key needed
Unauthenticated: the form page posts what the donor chose and gets a Stripe Checkout address to redirect to. Checked against the form (422 with a pointer per field; a fund deleted or deactivated since the page loaded is 422 /fund_id). The amount, fund, frequency and donor are kept here and never read back from Stripe. Card and US bank account payments; a monthly or yearly frequency starts a recurring donation once its first charge is paid. The gift is recorded and receipted when Stripe reports the money paid (ADR-013). Stripe sends the donor back to /give/<slug>/thanks after paying, or to /give/<slug>?canceled=1 if they cancel. An unknown, inactive or deleted form is 404, and so is a form whose Stripe account can no longer take charges. Rate-limited to 10 requests per minute per client IP. 503 while online giving isn't fully set up on this deployment, or when Stripe cannot start the payment within 8 seconds; nothing is charged, and the checkout is closed with the donor's details erased (a retry starts afresh).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | The form's public address, as in |
Request body (CheckoutSessionCreate)
| Name | Type | Required | Description |
|---|---|---|---|
amount_cents | integer (int64) | Yes | Integer cents. Charged each time for a monthly or yearly gift. |
frequency | one of: one_time, month, year | Yes |
|
fund_id | string (uuid) | Yes | |
first_name | string | Yes | |
last_name | string | Yes | |
email | string (email) | Yes | The receipt goes to the donor record with this email. A donor whose email matches a contact is matched to that contact. |
email_opt_in | boolean | Yes | The box "Email me news and appeals from <organization>" was left ticked (ADR-024, rule B): it subscribes a contact the form creates, or a matched contact whose name matches; otherwise, and for anyone who unsubscribed, a confirmation email is sent. Unticked changes nothing; never unsubscribes anyone. |
email_ref | string | No | The |
tribute_type | one of: in_honor_of, in_memory_of or any or null | No | Give in honor or in memory of someone named in |
tribute_name | string or null | No |
Example request
{
"amount_cents": 2500,
"frequency": "month",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"first_name": "Aiko",
"last_name": "Nakamura",
"email": "aiko.nakamura@example.com",
"email_opt_in": true
}Responses
201A Stripe Checkout session.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Online giving isn't fully set up on this deployment (the Stripe secret key, the webhook signing secret and APP_ORIGIN must all be set, so nothing half-configured can take money), or Stripe could not be reached or could not start the payment. Nothing was charged and nothing the donor typed was kept; try again later.
Example response (201)
{
"url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3D4e5F6g7H8"
}Recurring donations
Monthly and yearly gifts started on a donation form. A recurring donation is a promise, not money, and no report counts it; each paid charge is one recurring gift.
List recurring donations
GET /v1/recurring-donations
Sorted by started_at descending, then id. Filter by donor or status.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
contact_id | string (uuid) | No | |
status | one of: active, past_due, canceled | No |
Responses
200A page of recurring donations.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d52",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"amount_cents": 2500,
"interval": "month",
"status": "active",
"cancel_at_period_end": false,
"current_period_end": "2026-12-04T22:15:00Z",
"started_at": "2026-10-04T22:15:00Z",
"canceled_at": null,
"stripe_subscription_id": "sub_1QAbCd2eZvKYlo2CfGhIjKlM",
"stripe_customer_id": "cus_QAbCdEfGhIjKlM",
"tribute_type": "in_memory_of",
"tribute_name": "Grace Okafor",
"created_at": "2026-10-04T22:15:03Z",
"updated_at": "2026-11-04T22:15:04Z"
}
],
"next_cursor": null
}Get a recurring donation
GET /v1/recurring-donations/{id}
Its charges are the gifts with this recurring_donation_id.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The recurring donation.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d52",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"amount_cents": 2500,
"interval": "month",
"status": "active",
"cancel_at_period_end": false,
"current_period_end": "2026-12-04T22:15:00Z",
"started_at": "2026-10-04T22:15:00Z",
"canceled_at": null,
"stripe_subscription_id": "sub_1QAbCd2eZvKYlo2CfGhIjKlM",
"stripe_customer_id": "cus_QAbCdEfGhIjKlM",
"tribute_type": "in_memory_of",
"tribute_name": "Grace Okafor",
"created_at": "2026-10-04T22:15:03Z",
"updated_at": "2026-11-04T22:15:04Z"
}Cancel a recurring donation at the end of its period
POST /v1/recurring-donations/{id}/cancel
Tells Stripe to end the subscription when the current period ends (cancel_at_period_end), so the donor is not charged again; undo with resume until then. When the period ends, Stripe's webhook sets status: canceled. Already ending, or already canceled: 200 with nothing changed. Stripe is called first and the record changes only when Stripe agreed: 409 when Stripe refuses (the subscription already ended there), 503 while online giving isn't fully set up on this deployment, or when Stripe cannot be reached. Emits recurring_donation.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The recurring donation.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Online giving isn't fully set up on this deployment (the Stripe secret key, the webhook signing secret and APP_ORIGIN must all be set, so nothing half-configured can take money), or Stripe could not be reached or could not start the payment. Nothing was charged and nothing the donor typed was kept; try again later.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d52",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"amount_cents": 2500,
"interval": "month",
"status": "active",
"cancel_at_period_end": true,
"current_period_end": "2026-12-04T22:15:00Z",
"started_at": "2026-10-04T22:15:00Z",
"canceled_at": null,
"stripe_subscription_id": "sub_1QAbCd2eZvKYlo2CfGhIjKlM",
"stripe_customer_id": "cus_QAbCdEfGhIjKlM",
"tribute_type": "in_memory_of",
"tribute_name": "Grace Okafor",
"created_at": "2026-10-04T22:15:03Z",
"updated_at": "2026-11-20T15:00:00Z"
}Undo a cancel before the period ends
POST /v1/recurring-donations/{id}/resume
Clears cancel_at_period_end at Stripe, so charges continue. Not ending: 200 with nothing changed. 409 once the recurring donation is canceled (the donor starts a new one on the form) or when Stripe refuses; 503 while online giving isn't fully set up on this deployment, or when Stripe cannot be reached. Emits recurring_donation.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The recurring donation.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Online giving isn't fully set up on this deployment (the Stripe secret key, the webhook signing secret and APP_ORIGIN must all be set, so nothing half-configured can take money), or Stripe could not be reached or could not start the payment. Nothing was charged and nothing the donor typed was kept; try again later.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d52",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"contact_name": "Aiko Nakamura",
"fund_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d09",
"fund_name": "Winter Shelter Fund",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
"appeal_id": null,
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d51",
"amount_cents": 2500,
"interval": "month",
"status": "active",
"cancel_at_period_end": false,
"current_period_end": "2026-12-04T22:15:00Z",
"started_at": "2026-10-04T22:15:00Z",
"canceled_at": null,
"stripe_subscription_id": "sub_1QAbCd2eZvKYlo2CfGhIjKlM",
"stripe_customer_id": "cus_QAbCdEfGhIjKlM",
"tribute_type": "in_memory_of",
"tribute_name": "Grace Okafor",
"created_at": "2026-10-04T22:15:03Z",
"updated_at": "2026-11-04T22:15:04Z"
}Receipts and other mail to donors (ADR-014). Requests only queue; a job sends each message within about a minute through Postmark and retries when Postmark cannot be reached. Nobody who is deleted, marked do-not-contact, without an email address or on the organization's suppression list is ever emailed; such a message is recorded as suppressed with the reason, so staff can post it instead.
Get the email settings
GET /v1/email-settings
The sender settings, the address mail goes out from now, the sending domain with its DNS records, the secret BCC capture address, today's sends against the daily limit, and what this deployment has set up, including whether captured email is filed automatically (ADR-014). Read access: everyone who emails donors or BCCs the capture address needs to see them.
Responses
200The email settings.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"email_from_name": null,
"email_from_local_part": "giving",
"email_reply_to": "office@maplestreetfoodbank.org",
"from_name": "Maple Street Food Bank",
"from_address": "giving@maplestreetfoodbank.org",
"bcc_address": "inbound+k7m2q4x7w3p5r6t2v3n4b7c3@capture.operationcharity.org",
"sending_domain": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
},
"sending_today": {
"sent": 42,
"limit": 5000,
"resets_at": "2026-10-13T00:00:00Z"
},
"configured": {
"sending": true,
"inbound": true,
"inbound_auto_file": true,
"domains": true
}
}Change the sender settings
PATCH /v1/email-settings
Admin only. The display name, the part of the sending address before the @, and the Reply-To address. A message already queued goes out with the settings in force when it is sent. Audited; emits no event, since email settings are configuration like webhook subscriptions (ADR-001).
Request body (EmailSettingsUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
email_from_name | string or null | No | 1–100 characters, without quotes, angle brackets or control characters (they could break the From header). |
email_from_local_part | string | No | Lowercase letters, digits, dots, hyphens and underscores, starting with a letter or digit, with no two dots in a row and no dot at the end. |
email_reply_to | string or null | No | One email address. |
Example request
{
"email_from_name": "Maple Street Food Bank",
"email_from_local_part": "donations",
"email_reply_to": "office@maplestreetfoodbank.org"
}Responses
200The email settings.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"email_from_name": null,
"email_from_local_part": "giving",
"email_reply_to": "office@maplestreetfoodbank.org",
"from_name": "Maple Street Food Bank",
"from_address": "giving@maplestreetfoodbank.org",
"bcc_address": "inbound+k7m2q4x7w3p5r6t2v3n4b7c3@capture.operationcharity.org",
"sending_domain": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
},
"sending_today": {
"sent": 42,
"limit": 5000,
"resets_at": "2026-10-13T00:00:00Z"
},
"configured": {
"sending": true,
"inbound": true,
"inbound_auto_file": true,
"domains": true
}
}Add the organization's own sending domain
POST /v1/email-settings/sending-domain
Admin only. Claims the domain: registers it with Postmark (or reuses Postmark's existing registration, which claims on one domain share) and returns the records to add at the domain's DNS host: the ownership TXT record, the DKIM TXT record and the Return-Path CNAME record; then call verify. Mail keeps coming from the platform's domain until all three are verified. A claim holds nothing until ownership is proven, so another organization's claim never stops you adding one; proving it is refused while another organization holds it proven (see verify). Adding a domain this organization removed earlier restores that claim instead (200), with its DNS records and ownership proof as they were and no Postmark call; restoring a proven claim is refused while another organization holds the domain proven (409). One live domain per organization: 409 while there is one (remove it first). 422 for a malformed domain or one of the platform's own. 409 with Postmark's reason when Postmark refuses the domain; 503 while organizations' own domains aren't set up on this deployment, or when Postmark cannot be reached. Audited.
Request body (SendingDomainCreate)
| Name | Type | Required | Description |
|---|---|---|---|
domain | string | Yes | The domain mail should come from, e.g. maplestreetfoodbank.org: no address, no scheme. Case and a trailing dot are ignored. |
Example request
{
"domain": "maplestreetfoodbank.org"
}Responses
200The organization's removed claim on this domain, restored as it was.201The domain, pending until all three DNS records are verified.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Email isn't set up on this deployment for what was asked (sending needs the Postmark server token, the platform sending domain and APP_ORIGIN; organizations' own sending domains need the Postmark account token), or Postmark could not be reached. Nothing was sent or changed; try again later (ADR-014).
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
}Remove the sending domain
DELETE /v1/email-settings/sending-domain
Admin only. Soft delete (ADR-005): mail comes from the platform's domain from the next send on; undo with POST /v1/email-settings/sending-domain/restore. The domain stays registered with Postmark (other claims may share it), so restoring it needs no new DNS records. A removed claim holds nothing: another organization may then prove the domain. 404 when there is no live domain. Audited.
Responses
204Removed.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Check the sending domain's DNS records
POST /v1/email-settings/sending-domain/verify
Admin only. Looks up the ownership TXT record in DNS (until it is proven; proven once, it stays proven) and asks Postmark to look up the DKIM and Return-Path records again, and stores what was found; mail uses the domain only while all three are verified. 409 when the ownership record is found but another organization has already proven it owns the domain (nothing is changed), or when the domain was never registered with Postmark; 404 when there is no live domain; 503 while organizations' own domains aren't set up on this deployment, or when Postmark cannot be reached. Audited.
Responses
200The domain with what was found.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Email isn't set up on this deployment for what was asked (sending needs the Postmark server token, the platform sending domain and APP_ORIGIN; organizations' own sending domains need the Postmark account token), or Postmark could not be reached. Nothing was sent or changed; try again later (ADR-014).
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
}Restore the removed sending domain
POST /v1/email-settings/sending-domain/restore
Admin only. Undoes the most recent removal; the domain comes back as verified as it was. 409 while another domain is live (remove it first), and 409 for a proven claim while another organization has since proven it owns the domain; 404 when no domain was removed. Audited.
Responses
200The restored domain, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
}Send a test email to yourself
POST /v1/email-settings/test-email
Admin only (API keys get 403). A signed-in admin queues a short test message to their own address, the one on their user, sent with exactly the From and Reply-To receipts get now, to check the setup end to end; follow it with GET /v1/emails/{id}. The request names no recipient, so it cannot send to anyone else. It goes on no contact's timeline. 409 https://docs.operationcharity.org/problems/not-emailed when your own address is on the suppression list (the attempt is recorded as a suppressed email); 422 when your user has no usable address; 503 while sending isn't set up on this deployment.
Responses
202The queued test email; it is sent within about a minute.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Email isn't set up on this deployment for what was asked (sending needs the Postmark server token, the platform sending domain and APP_ORIGIN; organizations' own sending domains need the Postmark account token), or Postmark could not be reached. Nothing was sent or changed; try again later (ADR-014).
Example response (202)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d69",
"kind": "test",
"contact_id": null,
"receipt_id": null,
"welcome_enrollment_id": null,
"welcome_step": null,
"stream": "transactional",
"from_address": "\"Maple Street Food Bank\" <giving@maplestreetfoodbank.org>",
"reply_to": "office@maplestreetfoodbank.org",
"to_address": "dana@maplestreetfoodbank.org",
"subject": "Test email from Maple Street Food Bank",
"text_body": "This is a test email from Operation Charity, sent the way receipts to your donors are sent.\n\nFrom: \"Maple Street Food Bank\" <giving@maplestreetfoodbank.org>\nReplies go to: office@maplestreetfoodbank.org\n",
"html_body": null,
"attach_receipt_pdf": false,
"status": "queued",
"attempt": 0,
"next_attempt_at": "2026-10-12T15:00:00Z",
"postmark_message_id": null,
"error": null,
"sent_at": null,
"bodies_erased_at": null,
"email_send_id": null,
"delivered_at": null,
"unsubscribed_at": null,
"complained_at": null,
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z"
}List emails
GET /v1/emails
The outbox and its history, for example a receipt's emails (receipt_id) or an email send's (email_send_id, ADR-023). Sorted by created_at descending, then id. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
contact_id | string (uuid) | No | |
receipt_id | string (uuid) | No | |
status | one of: queued, sent, failed, suppressed, bounced | No | |
kind | one of: receipt, welcome_thank_you, welcome_impact, welcome_ask, test, appeal, appeal_test, send_notice, signup_confirmation | No | |
email_send_id | string (uuid) | No | An email send's messages: each recipient's status and reason, with delivered_at, unsubscribed_at and complained_at (ADR-023). |
Responses
200A page of emails.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d61",
"kind": "receipt",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"receipt_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"welcome_enrollment_id": null,
"welcome_step": null,
"stream": "transactional",
"from_address": "\"Maple Street Food Bank\" <giving@maplestreetfoodbank.org>",
"reply_to": "office@maplestreetfoodbank.org",
"to_address": "aiko.nakamura@example.com",
"subject": "Your receipt from Maple Street Food Bank",
"text_body": "Maple Street Food Bank\n1200 Maple Street\nDenver, CO 80202\nEIN 84-1234567\n\nContribution receipt\n…",
"html_body": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Contribution receipt</title></head><body><main class=\"receipt\">…</main></body></html>",
"attach_receipt_pdf": true,
"status": "sent",
"attempt": 1,
"next_attempt_at": null,
"postmark_message_id": "0a129aee-e1cd-480d-b08d-4f48548ff48d",
"error": null,
"sent_at": "2026-10-04T22:20:41Z",
"bodies_erased_at": null,
"email_send_id": null,
"delivered_at": "2026-10-04T22:20:44Z",
"unsubscribed_at": null,
"complained_at": null,
"created_at": "2026-10-04T22:20:00Z",
"updated_at": "2026-10-04T22:20:41Z"
}
],
"next_cursor": null
}Get an email
GET /v1/emails/{id}
Its bodies are null once erased, 30 days after it reached a final status, and on a suppressed email, which never keeps them.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The email.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d61",
"kind": "receipt",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"receipt_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"welcome_enrollment_id": null,
"welcome_step": null,
"stream": "transactional",
"from_address": "\"Maple Street Food Bank\" <giving@maplestreetfoodbank.org>",
"reply_to": "office@maplestreetfoodbank.org",
"to_address": "aiko.nakamura@example.com",
"subject": "Your receipt from Maple Street Food Bank",
"text_body": "Maple Street Food Bank\n1200 Maple Street\nDenver, CO 80202\nEIN 84-1234567\n\nContribution receipt\n…",
"html_body": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Contribution receipt</title></head><body><main class=\"receipt\">…</main></body></html>",
"attach_receipt_pdf": true,
"status": "sent",
"attempt": 1,
"next_attempt_at": null,
"postmark_message_id": "0a129aee-e1cd-480d-b08d-4f48548ff48d",
"error": null,
"sent_at": "2026-10-04T22:20:41Z",
"bodies_erased_at": null,
"email_send_id": null,
"delivered_at": "2026-10-04T22:20:44Z",
"unsubscribed_at": null,
"complained_at": null,
"created_at": "2026-10-04T22:20:00Z",
"updated_at": "2026-10-04T22:20:41Z"
}Retry a failed email
POST /v1/emails/{id}/retry
Queues a failed email for one more attempt now; if that attempt fails too, it is failed again at once. Every check is made again when it is sent, so a donor marked do-not-contact since then is not emailed. 409 unless the email is failed, when its receipt already has an email queued, or when its text was erased (30 days after it failed; for a receipt, POST /v1/receipts/{id}/send emails it again); 503 while sending isn't set up on this deployment.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The email, queued again.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Email isn't set up on this deployment for what was asked (sending needs the Postmark server token, the platform sending domain and APP_ORIGIN; organizations' own sending domains need the Postmark account token), or Postmark could not be reached. Nothing was sent or changed; try again later (ADR-014).
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d65",
"kind": "receipt",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"receipt_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d16",
"welcome_enrollment_id": null,
"welcome_step": null,
"stream": "transactional",
"from_address": "\"Maple Street Food Bank\" <giving@maplestreetfoodbank.org>",
"reply_to": "office@maplestreetfoodbank.org",
"to_address": "aiko.nakamura@example.com",
"subject": "Your receipt from Maple Street Food Bank",
"text_body": "Maple Street Food Bank\n…",
"html_body": "<!doctype html>…",
"attach_receipt_pdf": true,
"status": "queued",
"attempt": 0,
"next_attempt_at": "2026-10-12T15:00:00Z",
"postmark_message_id": null,
"error": null,
"sent_at": null,
"bodies_erased_at": null,
"email_send_id": null,
"delivered_at": null,
"unsubscribed_at": null,
"complained_at": null,
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z"
}List suppressed addresses
GET /v1/email-suppressions
Addresses this organization never emails. Sorted by created_at descending, then id. Reactivated (deleted) addresses are excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
email | string | No | One address, compared without regard to case. |
reason | one of: hard_bounce, spam_complaint, unsubscribe, manual | No |
Responses
200A page of suppressions.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d63",
"email": "old.address@example.org",
"reason": "hard_bounce",
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d61",
"created_at": "2026-10-04T22:21:05Z",
"updated_at": "2026-10-04T22:21:05Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Suppress an address by hand
POST /v1/email-suppressions
Admin only. Adds the address with reason manual, staff's explicit choice, which stops every email to it, receipts included (ADR-014); undo with DELETE. 409 when the address is already suppressed by hand; 422 for anything but one email address. Audited (0014's lifecycle trigger covers removal and restore; the insert is audited here). Emits no event.
Request body (EmailSuppressionCreate)
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | One email address; stored lowercased. |
reason | one of: manual | Yes |
Example request
{
"email": "pat.rivera@example.org",
"reason": "manual"
}Responses
201The suppression.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d68",
"email": "pat.rivera@example.org",
"reason": "manual",
"email_message_id": null,
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get a suppression
GET /v1/email-suppressions/{id}
A reactivated (deleted) suppression is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The suppression.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d63",
"email": "old.address@example.org",
"reason": "hard_bounce",
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d61",
"created_at": "2026-10-04T22:21:05Z",
"updated_at": "2026-10-04T22:21:05Z",
"deleted_at": null,
"deleted_by": null
}Reactivate a suppressed address
DELETE /v1/email-suppressions/{id}
Admin only. Soft delete (ADR-005): the address is emailed again; undo with POST /v1/email-suppressions/{id}/restore. Postmark may still refuse an address it deactivated after a hard bounce or spam complaint; such an email is then suppressed again. Emits no event; audited.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Reactivated.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Suppress a reactivated address again
POST /v1/email-suppressions/{id}/restore
Admin only. Clears deleted_at and deleted_by. 409 if the suppression is not deleted, or when the address has been suppressed again for the same reason since. Emits no event; audited.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The suppression, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d63",
"email": "old.address@example.org",
"reason": "hard_bounce",
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d61",
"created_at": "2026-10-04T22:21:05Z",
"updated_at": "2026-10-04T22:21:05Z",
"deleted_at": null,
"deleted_by": null
}Reopen the sign-up page
POST /v1/email-settings/signup-page/reopen
A signed-in admin reopens a sign-up page that closed itself (bounces or complaints on confirmations). The page's check then gets a little harder. 409 when it isn't closed. Audited.
Responses
200Reopened.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"email_from_name": null,
"email_from_local_part": "giving",
"email_reply_to": "office@maplestreetfoodbank.org",
"from_name": "Maple Street Food Bank",
"from_address": "giving@maplestreetfoodbank.org",
"bcc_address": "inbound+k7m2q4x7w3p5r6t2v3n4b7c3@capture.operationcharity.org",
"sending_domain": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
},
"sending_today": {
"sent": 42,
"limit": 5000,
"resets_at": "2026-10-13T00:00:00Z"
},
"configured": {
"sending": true,
"inbound": true,
"inbound_auto_file": true,
"domains": true
}
}Welcome series
Three emails to each new donor (ADR-015), sent through the email outbox at 10:00 in the organization's timezone - a thank-you at once, an impact story at day 14 and a second ask at day 45 - with each step's timing and copy set by the organization. Off until an admin turns it on, and only donors whose first gift is entered after that are enrolled. The impact story and the ask need email opt-in unless the organization turns that off, and carry a one-click unsubscribe. Every send is checked again at its time; staff can stop and resume any donor's series.
Get the welcome series
GET /v1/welcome-series
The series' settings and its three steps' copy (ADR-015), and whether this deployment is ready for it to send (missing lists what is not set up). The first read creates it with the default copy, turned off. Read access: everyone who works with donors can see what they are sent.
Responses
200The welcome series.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"enabled": true,
"enabled_at": "2026-10-01T16:00:00Z",
"marketing_requires_opt_in": true,
"steps": {
"thank_you": {
"enabled": true,
"delay_days": 0,
"subject": "Thank you, {first_name}",
"body": "Dear {first_name},\n\nThank you so much for {gift_amount} to {organization_name}. We're grateful for your support.\n\nYour gift goes to our {fund_name}.\n\nWith thanks,\n{organization_name}",
"stream": "transactional"
},
"impact_story": {
"enabled": true,
"delay_days": 14,
"subject": "What your gift did this month",
"body": "Dear {first_name},\n\nThis month your gift to our {fund_name} helped keep the shelter open every night of the cold snap: 412 warm beds and dinners.\n\nThank you for being part of it.\n\n{organization_name}",
"stream": "broadcast"
},
"second_ask": {
"enabled": true,
"delay_days": 45,
"subject": "Would you give again?",
"body": "Dear {first_name},\n\nThank you again for your gift to our {fund_name}. It meant a lot to us.\n\nIf you're able, would you consider {suggested_ask} to keep this work going?\n\nYou can give here: {donate_url}\n\nWhatever you decide, thank you for being part of {organization_name}.\n\nWith thanks,\n{organization_name}",
"stream": "broadcast"
}
},
"merge_fields": [
"{first_name}",
"{organization_name}",
"{gift_amount}",
"{fund_name}",
"{suggested_ask}",
"{donate_url}"
],
"sending_configured": true,
"unsubscribe_configured": true,
"ready": true,
"missing": [],
"updated_at": "2026-10-01T16:00:00Z"
}Change the welcome series
PATCH /v1/welcome-series
Admin only. Any subset of the settings and of each step's fields. Subjects and bodies may use only the merge fields, written exactly: an unknown or misspelt field is a 422 at its pointer, naming the field it was most likely meant to be ("{First_Name} is not a merge field. Did you mean {first_name}?"); {donate_url} is refused in a subject. Enabled steps' delays must not decrease in step order (422). Turning the series on needs the deployment to be ready, with email sending and unsubscribe links set up (409 naming what is missing, as missing does), and sets enabled_at to now each time, so only first gifts entered from then on enrol donors. Turning it off stops sending at once: active enrolments wait, and a step reached more than 3 days after it was due is skipped ("Missed while the series was off"), so no donor gets a late email. Audited; emits no event (configuration, ADR-001).
Request body (WelcomeSeriesUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
enabled | boolean | No | |
marketing_requires_opt_in | boolean | No | |
steps | object | No |
Example request
{
"enabled": true
}Responses
200The welcome series.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"enabled": true,
"enabled_at": "2026-10-01T16:00:00Z",
"marketing_requires_opt_in": true,
"steps": {
"thank_you": {
"enabled": true,
"delay_days": 0,
"subject": "Thank you, {first_name}",
"body": "Dear {first_name},\n\nThank you so much for {gift_amount} to {organization_name}. We're grateful for your support.\n\nYour gift goes to our {fund_name}.\n\nWith thanks,\n{organization_name}",
"stream": "transactional"
},
"impact_story": {
"enabled": true,
"delay_days": 14,
"subject": "What your gift did this month",
"body": "Dear {first_name},\n\nThis month your gift to our {fund_name} helped keep the shelter open every night of the cold snap: 412 warm beds and dinners.\n\nThank you for being part of it.\n\n{organization_name}",
"stream": "broadcast"
},
"second_ask": {
"enabled": true,
"delay_days": 45,
"subject": "Would you give again?",
"body": "Dear {first_name},\n\nThank you again for your gift to our {fund_name}. It meant a lot to us.\n\nIf you're able, would you consider {suggested_ask} to keep this work going?\n\nYou can give here: {donate_url}\n\nWhatever you decide, thank you for being part of {organization_name}.\n\nWith thanks,\n{organization_name}",
"stream": "broadcast"
}
},
"merge_fields": [
"{first_name}",
"{organization_name}",
"{gift_amount}",
"{fund_name}",
"{suggested_ask}",
"{donate_url}"
],
"sending_configured": true,
"unsubscribe_configured": true,
"ready": true,
"missing": [],
"updated_at": "2026-10-01T16:00:00Z"
}Preview a welcome email
POST /v1/welcome-series/preview
Read access. Renders one step exactly as it would be sent, with the saved copy or an unsaved subject and body, for a contact (their first gift, its fund and their suggested ask) or with sample values. Stores and sends nothing. Unsaved copy is checked like PATCH /v1/welcome-series (422 at /subject or /body); 422 at /contact_id for a contact that does not exist.
Request body (WelcomePreviewRequest)
| Name | Type | Required | Description |
|---|---|---|---|
step | one of: thank_you, impact_story, second_ask | Yes | The series' steps in the order they are sent: |
contact_id | string (uuid) or null | No | Render with this contact's own values: their first gift, its fund and their suggested ask. Omitted or null: sample values. |
subject | string | No | An unsaved subject to render instead of the saved one. |
body | string | No | An unsaved body to render instead of the saved one. |
Example request
{
"step": "thank_you",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}Responses
200The rendered email.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"step": "thank_you",
"stream": "transactional",
"subject": "Thank you, Aiko",
"text": "Dear Aiko,\n\nThank you so much for $50.00 to Maple Street Food Bank. We're grateful for your support.\n\nYour gift goes to our Winter Shelter Fund.\n\nWith thanks,\nMaple Street Food Bank",
"html": "<p>Dear Aiko,</p>\n<p>Thank you so much for $50.00 to Maple Street Food Bank. We're grateful for your support.</p>\n<p>Your gift goes to our Winter Shelter Fund.</p>\n<p>With thanks,<br>\nMaple Street Food Bank</p>",
"sample": false
}List welcome enrolments
GET /v1/welcome-enrollments
Each donor's run through the welcome series, with its step outcomes. Sorted by enrolled_at descending, then id.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
contact_id | string (uuid) | No | Only this contact's enrolment. |
status | one of: active, completed, stopped | No |
Responses
200A page of enrolments.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d70",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"first_gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"status": "active",
"next_step": "impact_story",
"next_step_at": "2026-10-19T16:00:00Z",
"stop_reason": null,
"enrolled_at": "2026-10-05T14:45:00Z",
"completed_at": null,
"stopped_at": null,
"steps": [
{
"step": "thank_you",
"outcome": "queued",
"reason": null,
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"email_status": "sent",
"email_sent_at": "2026-10-05T14:45:41Z",
"email_error": null,
"created_at": "2026-10-05T14:45:00Z"
}
],
"created_at": "2026-10-05T14:45:00Z",
"updated_at": "2026-10-05T14:45:00Z"
}
],
"next_cursor": null
}Get a welcome enrolment
GET /v1/welcome-enrollments/{id}
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The enrolment.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d70",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"first_gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"status": "active",
"next_step": "impact_story",
"next_step_at": "2026-10-19T16:00:00Z",
"stop_reason": null,
"enrolled_at": "2026-10-05T14:45:00Z",
"completed_at": null,
"stopped_at": null,
"steps": [
{
"step": "thank_you",
"outcome": "queued",
"reason": null,
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"email_status": "sent",
"email_sent_at": "2026-10-05T14:45:41Z",
"email_error": null,
"created_at": "2026-10-05T14:45:00Z"
}
],
"created_at": "2026-10-05T14:45:00Z",
"updated_at": "2026-10-05T14:45:00Z"
}Stop a donor's welcome series
POST /v1/welcome-enrollments/{id}/stop
Write access. Nothing more is sent to this donor from the series; an email already handed to the outbox still goes. The reason defaults to "Stopped by <your name>". Undo with resume. 409 unless the enrolment is active. Emits welcome_enrollment.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (WelcomeEnrollmentStop)
| Name | Type | Required | Description |
|---|---|---|---|
reason | string | No | Why, in plain words. Omitted: "Stopped by <your name>". |
Example request
{
"reason": "Asked us by phone not to email for now."
}Responses
200The stopped enrolment.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d70",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"first_gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"status": "stopped",
"next_step": null,
"next_step_at": null,
"stop_reason": "Stopped by Luis Ortega",
"enrolled_at": "2026-10-05T14:45:00Z",
"completed_at": null,
"stopped_at": "2026-10-08T15:00:00Z",
"steps": [
{
"step": "thank_you",
"outcome": "queued",
"reason": null,
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"email_status": "sent",
"email_sent_at": "2026-10-05T14:45:41Z",
"email_error": null,
"created_at": "2026-10-05T14:45:00Z"
}
],
"created_at": "2026-10-05T14:45:00Z",
"updated_at": "2026-10-08T15:00:00Z"
}Resume a stopped welcome series
POST /v1/welcome-enrollments/{id}/resume
Write access. Continues with the next turned-on step that has not been sent or skipped, at its usual time, or at once if that time has passed. 409 when the enrolment is not stopped, is completed, or has no step left. Emits welcome_enrollment.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The active enrolment.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d70",
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"first_gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
"status": "active",
"next_step": "impact_story",
"next_step_at": "2026-10-19T16:00:00Z",
"stop_reason": null,
"enrolled_at": "2026-10-05T14:45:00Z",
"completed_at": null,
"stopped_at": null,
"steps": [
{
"step": "thank_you",
"outcome": "queued",
"reason": null,
"email_message_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d71",
"email_status": "sent",
"email_sent_at": "2026-10-05T14:45:41Z",
"email_error": null,
"created_at": "2026-10-05T14:45:00Z"
}
],
"created_at": "2026-10-05T14:45:00Z",
"updated_at": "2026-10-05T14:45:00Z"
}Unsubscribe
The public unsubscribe link in welcome emails (ADR-015). Unauthenticated - the signed token in the link is the whole credential; it names one contact in one organization and never expires.
Check an unsubscribe link
GET /v1/public-unsubscribesNo key needed
Unauthenticated: what the public unsubscribe page at /unsubscribe?token=… shows before the donor confirms. A token that is malformed, tampered with or names nothing answers 404. Rate-limited to 60 requests per minute per client IP.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The |
Responses
200The organization's name and whether the donor is unsubscribed already.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Unsubscribe links aren't set up on this deployment (UNSUBSCRIBE_URL_SECRET, 32 or more characters, ADR-015). Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"already_unsubscribed": false
}Unsubscribe from an organization's email updates
POST /v1/public-unsubscribesNo key needed
Unauthenticated: the unsubscribe page's button. Clears the contact's email_opt_in, puts their address on the organization's suppression list as unsubscribe (it stops the welcome series' impact story and ask and any other broadcast mail; receipts still go, ADR-014), writes an audit row and emits contact.updated when the contact changed. Doing it again changes nothing. 404 for a bad token. Rate-limited to 20 requests per minute per client IP and 10 per minute per link, shared with the one-click address.
Request body (PublicUnsubscribeCreate)
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The |
Example request
{
"token": "eyJjIjoiM2Y5ZDJjMWEtN2I0ZS00ZjZhLTljMmQtMWU1YjhhN2M2ZDA1IiwibyI6IjNmOWQyYzFhLTdiNGUtNGY2YS05YzJkLTFlNWI4YTdjNmQwMSJ9.nM0sHqjvBQ2k7l3o0h4Yb0x5mI6lQ9w8Z7r2c1v8XyA"
}Responses
200Unsubscribed.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Unsubscribe links aren't set up on this deployment (UNSUBSCRIBE_URL_SECRET, 32 or more characters, ADR-015). Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"already_unsubscribed": true
}One-click unsubscribe from a mail client
POST /v1/public-unsubscribes/one-clickNo key needed
Unauthenticated: the List-Unsubscribe address of the welcome series' broadcast emails (RFC 8058). Mail clients post List-Unsubscribe=One-Click form-encoded to it; the body is not read. Same effect as POST /v1/public-unsubscribes. 404 for a bad token. Rate-limited to 600 requests per minute per client IP (mail providers post from shared addresses) and 10 per minute per link, shared with POST /v1/public-unsubscribes. Opening the address with GET instead (some clients do) redirects (303) to the confirmation page <APP_ORIGIN>/unsubscribe?token=… and changes nothing; that redirect is served by the web app.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The |
Request body (object)
| Name | Type | Required | Description |
|---|---|---|---|
List-Unsubscribe | one of: One-Click | No |
Example request
{
"List-Unsubscribe": "One-Click"
}Responses
200Unsubscribed.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Unsubscribe links aren't set up on this deployment (UNSUBSCRIBE_URL_SECRET, 32 or more characters, ADR-015). Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"already_unsubscribed": true
}What the preferences page shows
GET /v1/public-email-preferencesNo key needed
Unauthenticated; the signed token from the email is the credential. No name and no address. 404 for a bad token. 60 a minute per client IP.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes |
Responses
200The person's choices.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Unsubscribe links aren't set up on this deployment (UNSUBSCRIBE_URL_SECRET, 32 or more characters, ADR-015). Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"subscribed": true,
"appeals": true,
"newsletters": true
}Change email preferences
POST /v1/public-email-preferencesNo key needed
Unauthenticated. The page only takes away: unsubscribe from everything, or stop appeals or newsletters. Recorded as a consent event (ADR-024); an unsubscribe also suppresses broadcast mail to the address; receipts still go. The answer's undo_id reverses exactly this change for 15 minutes, once, while it is the person's latest change.
Request body (PublicEmailPreferencesChange)
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | |
change | one of: unsubscribe, stop_appeals, stop_newsletters | Yes |
Example request
{
"token": "eyJjIjoiM2Y5ZCJ9.sig",
"change": "stop_appeals"
}Responses
200Changed (or already so).404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Unsubscribe links aren't set up on this deployment (UNSUBSCRIBE_URL_SECRET, 32 or more characters, ADR-015). Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"subscribed": true,
"appeals": false,
"newsletters": true,
"undo_id": "Zm9vYmFyYmF6cXV4cXV1eHF1dXgxMjM0NTY3OA"
}Undo a change on the preferences page
POST /v1/public-email-preferences/undoNo key needed
Unauthenticated. With the undo_id the change answered, within 15 minutes, once, while that change is the person's latest; never a one-click unsubscribe. undone false otherwise.
Request body (PublicEmailPreferencesUndo)
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | |
undo_id | string | Yes |
Example request
{
"token": "eyJjIjoiM2Y5ZCJ9.sig",
"undo_id": "Zm9vYmFyYmF6cXV4cXV1eHF1dXgxMjM0NTY3OA"
}Responses
200The choices after the undo.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Unsubscribe links aren't set up on this deployment (UNSUBSCRIBE_URL_SECRET, 32 or more characters, ADR-015). Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"subscribed": true,
"appeals": true,
"newsletters": true,
"undone": true
}What the sign-up page shows
GET /v1/public-email-signupsNo key needed
Unauthenticated. The organization's name, whether the page is open, and its proof-of-work challenge. 404 for an unknown code.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
code | string | Yes |
Responses
200The page.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"open": true,
"challenge": "eyJvIjoiM2Y5ZCJ9.c2ln",
"difficulty": 16
}Ask for email updates
POST /v1/public-email-signupsNo key needed
Unauthenticated. Records a pending sign-up and nothing else: it reads no contact and answers the same 202, in the same time, whatever the address, for a filled website field, while the page is closed and past the daily cap. A confirmation email goes out within a few minutes; the person is subscribed only once they confirm.
Request body (PublicEmailSignupCreate)
| Name | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The page's code ( |
first_name | string | Yes | |
last_name | string or null | No | |
email | string | Yes | |
website | string | No | Leave empty (a field people never see). |
challenge | string | Yes | |
nonce | string | Yes |
Example request
{
"code": "k7d2m4qx",
"first_name": "Dana",
"email": "dana@example.org",
"website": "",
"challenge": "eyJvIjoiM2Y5ZCJ9.c2ln",
"nonce": "1a2b"
}Responses
202Received.400Malformed request (bad cursor, unknown filter, unparseable body).404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (202)
{
"received": true
}What the confirmation page shows
GET /v1/public-email-signups/confirmNo key needed
Unauthenticated. Confirms nothing (a mail scanner opening the link changes nothing).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes |
Responses
200Whether the link can be used.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Sign-up confirmation links aren't set up on this deployment (EMAIL_SIGNUP_SECRET, 32 or more characters, ADR-024); it fails closed. Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"status": "ready"
}Confirm email updates
POST /v1/public-email-signups/confirmNo key needed
Unauthenticated: the confirmation page's button. Subscribes the contact the request named while its address is unchanged, else the oldest live contact with the address, else a new contact; never while a do-not-contact record holds the address, after a newer unsubscribe or complaint, after 48 hours or twice.
Request body (PublicEmailSignupConfirmRequest)
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes |
Example request
{
"token": "eyJzIjoiM2Y5ZCJ9.c2ln"
}Responses
200The outcome.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Sign-up confirmation links aren't set up on this deployment (EMAIL_SIGNUP_SECRET, 32 or more characters, ADR-024); it fails closed. Nothing was changed; try again later.
Example response (200)
{
"organization_name": "Maple Street Food Bank",
"status": "confirmed"
}Email sends
Appeals and newsletters (ADR-023): templates of blocks (never HTML), a send drafted from one, reviewed against a frozen snapshot of a saved list, approved by a signed-in admin with a fresh second step exactly as reviewed, then sent from the organization's own Postmark broadcast server inside the daily cap, the warm-up and 07:00 to 21:00 in its time zone, only to subscribed contacts (ADR-024). Results per send: delivered, bounced, unsubscribed, clicks per link per day (never opens), and the gifts its donation link brought in. Templates emit email_template.created|updated|deleted and sends email_send.created|updated|deleted.
List email templates
GET /v1/email-templates
Read access. Sorted by name (case-insensitive), then id. q matches the name. Soft-deleted templates are excluded unless include_deleted=true. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
q | string | No | Part of the name. |
Responses
200A page of templates.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"name": "Spring newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"updated_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:30:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Create an email template
POST /v1/email-templates
Write access. The content is checked whole (422 with a pointer per problem; unknown properties refused) and each donation link must name a live, active donation form. Emits email_template.created; audited with the whole row.
Request body (EmailTemplateCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | |
content | EmailContent | Yes | A template's or a send's content (ADR-023): a subject, a preheader and 1 to 50 blocks, at most 10 images and 25 links, and at most 90 KB of HTML once drawn (Gmail clips past about 102 KB, hiding the footer). Every problem is a 422 with its JSON pointer; unknown properties are refused. The footer - the organization's name, mailing address and the person's preferences link - is added to every email and no template can remove it. |
Example request
{
"name": "Spring newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
}
}Responses
201Created.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"name": "Spring newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"updated_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:30:00Z",
"deleted_at": null,
"deleted_by": null
}Get an email template
GET /v1/email-templates/{id}
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The template, deleted ones too.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"name": "Spring newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"updated_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:30:00Z",
"deleted_at": null,
"deleted_by": null
}Update an email template
PATCH /v1/email-templates/{id}
Write access. Sends already made from it keep their own copy. Emits email_template.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailTemplateUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | |
content | EmailContent | No | A template's or a send's content (ADR-023): a subject, a preheader and 1 to 50 blocks, at most 10 images and 25 links, and at most 90 KB of HTML once drawn (Gmail clips past about 102 KB, hiding the footer). Every problem is a 422 with its JSON pointer; unknown properties are refused. The footer - the organization's name, mailing address and the person's preferences link - is added to every email and no template can remove it. |
Example request
{
"name": "Spring newsletter (2026)"
}Responses
200Updated.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"name": "Spring newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"updated_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:30:00Z",
"deleted_at": null,
"deleted_by": null
}Delete an email template
DELETE /v1/email-templates/{id}
Soft delete (ADR-005); undo with restore. Emits email_template.deleted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted email template
POST /v1/email-templates/{id}/restore
Emits email_template.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200Restored.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"name": "Spring newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"updated_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"created_at": "2026-10-12T15:00:00Z",
"updated_at": "2026-10-12T15:30:00Z",
"deleted_at": null,
"deleted_by": null
}Preview an email template
POST /v1/email-templates/{id}/preview
The template (or unsaved content) as a contact, or sample values, would get it. Read access; stores nothing.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailPreviewRequest)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) or null | No | Draw it as this contact would get it; sample values when absent. |
content | EmailContent | No | A template's or a send's content (ADR-023): a subject, a preheader and 1 to 50 blocks, at most 10 images and 25 links, and at most 90 KB of HTML once drawn (Gmail clips past about 102 KB, hiding the footer). Every problem is a 422 with its JSON pointer; unknown properties are refused. The footer - the organization's name, mailing address and the person's preferences link - is added to every email and no template can remove it. |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}Responses
200The email as drawn.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"subject": "Dana, spring at the pantry",
"preheader": "What your gifts did this winter",
"html": "<!DOCTYPE html>\n<html lang=\"en\">…</html>\n",
"text": "Spring at the pantry\n\nThank you for $125.00. Read the whole story (https://maplestreetfoodbank.org/spring).\n\n--\nMaple Street Food Bank\n…",
"html_bytes": 4812,
"shown_as_donor": true
}List email sends
GET /v1/email-sends
Read access. Sorted by created_at, newest first, then id. status keeps sends in one status. Soft-deleted drafts are excluded unless include_deleted=true. Unknown query parameters are a 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
status | one of: draft, scheduled, sending, paused, sent, canceled | No |
Responses
200A page of sends.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": null,
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": null,
"members_hash": null,
"review": null,
"reviewed_at": null,
"reviewed_by": null,
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Draft an email send
POST /v1/email-sends
Write access. From a template (the send copies its content and then owns its copy) or its own content. The send takes its appeal's campaign. Emits email_send.created.
Request body (EmailSendCreate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | |
topic | one of: appeal, newsletter | Yes | |
template_id | string (uuid) | No | |
content | EmailContent | No | A template's or a send's content (ADR-023): a subject, a preheader and 1 to 50 blocks, at most 10 images and 25 links, and at most 90 KB of HTML once drawn (Gmail clips past about 102 KB, hiding the footer). Every problem is a 422 with its JSON pointer; unknown properties are refused. The footer - the organization's name, mailing address and the person's preferences link - is added to every email and no template can remove it. |
saved_list_id | string (uuid) or null | No | |
appeal_id | string (uuid) or null | No | |
household_option | one of: per_person, per_household | No | |
track_clicks | boolean | No |
Example request
{
"name": "Spring newsletter 2026",
"topic": "newsletter",
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08"
}Responses
201Created as a draft.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (201)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": null,
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": null,
"members_hash": null,
"review": null,
"reviewed_at": null,
"reviewed_by": null,
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Get an email send
GET /v1/email-sends/{id}
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The send, deleted ones too.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": null,
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": null,
"members_hash": null,
"review": null,
"reviewed_at": null,
"reviewed_by": null,
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Change a draft
PATCH /v1/email-sends/{id}
Drafts only (409 otherwise). Any change clears the review, so the send is reviewed again. Emits email_send.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailSendUpdate)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | No | |
topic | one of: appeal, newsletter | No | |
content | EmailContent | No | A template's or a send's content (ADR-023): a subject, a preheader and 1 to 50 blocks, at most 10 images and 25 links, and at most 90 KB of HTML once drawn (Gmail clips past about 102 KB, hiding the footer). Every problem is a 422 with its JSON pointer; unknown properties are refused. The footer - the organization's name, mailing address and the person's preferences link - is added to every email and no template can remove it. |
saved_list_id | string (uuid) or null | No | |
appeal_id | string (uuid) or null | No | |
household_option | one of: per_person, per_household | No | |
track_clicks | boolean | No |
Example request
{
"track_clicks": true
}Responses
200Updated.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": null,
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": null,
"members_hash": null,
"review": null,
"reviewed_at": null,
"reviewed_by": null,
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Delete a draft
DELETE /v1/email-sends/{id}
Soft delete (ADR-005); undo with restore. Drafts only (409 otherwise; cancel a scheduled or sending send instead). Emits email_send.deleted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Deleted.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore a deleted draft
POST /v1/email-sends/{id}/restore
Emits email_send.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200Restored.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": null,
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": null,
"members_hash": null,
"review": null,
"reviewed_at": null,
"reviewed_by": null,
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Preview a send
POST /v1/email-sends/{id}/preview
The send as a contact, or sample values, would get it. Read access; stores nothing.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailPreviewRequest)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) or null | No | Draw it as this contact would get it; sample values when absent. |
content | EmailContent | No | A template's or a send's content (ADR-023): a subject, a preheader and 1 to 50 blocks, at most 10 images and 25 links, and at most 90 KB of HTML once drawn (Gmail clips past about 102 KB, hiding the footer). Every problem is a 422 with its JSON pointer; unknown properties are refused. The footer - the organization's name, mailing address and the person's preferences link - is added to every email and no template can remove it. |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}Responses
200The email as drawn.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"subject": "Dana, spring at the pantry",
"preheader": "What your gifts did this winter",
"html": "<!DOCTYPE html>\n<html lang=\"en\">…</html>\n",
"text": "Spring at the pantry\n\nThank you for $125.00. Read the whole story (https://maplestreetfoodbank.org/spring).\n\n--\nMaple Street Food Bank\n…",
"html_bytes": 4812,
"shown_as_donor": true
}Send a test
POST /v1/email-sends/{id}/test
A signed-in staff member or admin only (read-only members and API keys get 403). The send as a chosen contact, or sample values, would get it, marked "[Test]", to the caller and up to five other active users; nobody else. One-to- one, with an inert preferences link and no attribution. Five a minute.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailSendTestRequest)
| Name | Type | Required | Description |
|---|---|---|---|
contact_id | string (uuid) or null | No | Draw it as this contact would get it; sample values when absent. |
user_ids | list of string (uuid) | No | Up to five other active users; the caller always gets it. |
Example request
{
"contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
"user_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03"
]
}Responses
202Queued; the drain sends it within about a minute.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (202)
{
"queued": 2,
"user_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03"
]
}Review a draft
POST /v1/email-sends/{id}/review
Write access. Freezes a snapshot of the send's saved list (ADR-020) and records, for each member, its address, its merge values and whether it may be emailed (ADR-024), and the hashes of the content and of those rows; review then holds what the approver sees. Needs a list and an appeal (409). A list with giving conditions counts against the evaluation bucket (429 while busy).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200Reviewed.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Working out a list's giving conditions took longer than 10 seconds, the gift load and the money library included (ADR-020). Nothing was changed; try again, or narrow the list. Lists are built for organizations of up to 20,000 contacts and 100,000 gifts.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
"members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"review": {
"list_name": "Lapsed shelter donors",
"list_version": 3,
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"member_count": 1240,
"eligible_count": 1102,
"skipped": [
{
"reason": "not_subscribed",
"words": "Not emailed: the contact hasn't subscribed to email updates.",
"count": 120
},
{
"reason": "no_address",
"words": "Not emailed: the contact has no email address.",
"count": 18
}
],
"subscribed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"imports": 0,
"staff": 4
},
"address_changed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"staff": 2,
"api_key": 0,
"import": 0
},
"link_hosts": [
"maplestreetfoodbank.org"
],
"image_hosts": [
"maplestreetfoodbank.org"
],
"sending_domain": "mail.maplestreetfoodbank.org",
"missing_settings": [],
"dmarc_warning": null,
"largest_email_bytes": 5120
},
"reviewed_at": "2026-10-13T14:00:00Z",
"reviewed_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-13T14:00:00Z",
"deleted_at": null,
"deleted_by": null
}Approve a reviewed send
POST /v1/email-sends/{id}/approveIdempotency-Key required
A signed-in admin whose authenticator-app code is from the last 5 minutes (403 two-step-recent otherwise; API keys never). Names the snapshot, content hash and members' hash the approver saw: 409 if any changed. 409 settings-missing names every missing setting (the broadcast server, its domain, the secrets, a reply-to, the mailing address, a valid time zone, Postmark's broadcast stream set up, and on production Postmark's written answers recorded in ADR-023). With drafter_cannot_approve on and two or more admins, the drafter gets 403. Starts at least 10 minutes later and emails every active admin. Emits email_send.updated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
Idempotency-Key (header) | string | Yes | Client-generated unique string (UUID recommended) stored for 24 hours per organization. A replay with the same key and the same body returns the stored response with |
Request body (EmailSendApproval)
| Name | Type | Required | Description |
|---|---|---|---|
snapshot_id | string (uuid) | Yes | |
content_hash | string | Yes | |
members_hash | string | Yes | |
start_at | string (date-time) or null | No | A later start; never earlier than 10 minutes after approval. |
Example request
{
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
"members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
}Responses
200Approved and scheduled.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409The send can't be approved as it stands: type `settings-missing`, whose extension member `missing` lists each missing setting by name (ADR-023); or it changed after the review the approver saw, or isn't reviewed (type `conflict`). Nothing was approved.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.503Email isn't set up on this deployment for what was asked (sending needs the Postmark server token, the platform sending domain and APP_ORIGIN; organizations' own sending domains need the Postmark account token), or Postmark could not be reached. Nothing was sent or changed; try again later (ADR-014).
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "scheduled",
"content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
"members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"review": {
"list_name": "Lapsed shelter donors",
"list_version": 3,
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"member_count": 1240,
"eligible_count": 1102,
"skipped": [
{
"reason": "not_subscribed",
"words": "Not emailed: the contact hasn't subscribed to email updates.",
"count": 120
},
{
"reason": "no_address",
"words": "Not emailed: the contact has no email address.",
"count": 18
}
],
"subscribed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"imports": 0,
"staff": 4
},
"address_changed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"staff": 2,
"api_key": 0,
"import": 0
},
"link_hosts": [
"maplestreetfoodbank.org"
],
"image_hosts": [
"maplestreetfoodbank.org"
],
"sending_domain": "mail.maplestreetfoodbank.org",
"missing_settings": [],
"dmarc_warning": null,
"largest_email_bytes": 5120
},
"reviewed_at": "2026-10-13T14:00:00Z",
"reviewed_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"approved_at": "2026-10-13T14:05:00Z",
"approved_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"scheduled_for": "2026-10-13T14:15:00Z",
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-13T14:05:00Z",
"deleted_at": null,
"deleted_by": null
}Unschedule a send before it starts
POST /v1/email-sends/{id}/unschedule
Write access, before the start. The send is a draft again, its review and approval cleared, to be reviewed and approved anew.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200A draft again.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": null,
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "draft",
"content_hash": null,
"members_hash": null,
"review": null,
"reviewed_at": null,
"reviewed_by": null,
"approved_at": null,
"approved_by": null,
"scheduled_for": null,
"started_at": null,
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-12T16:00:00Z",
"deleted_at": null,
"deleted_by": null
}Pause a send that is going out
POST /v1/email-sends/{id}/pause
Write access. What has gone has gone; nothing more goes until an admin resumes it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200Paused.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "paused",
"content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
"members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"review": {
"list_name": "Lapsed shelter donors",
"list_version": 3,
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"member_count": 1240,
"eligible_count": 1102,
"skipped": [
{
"reason": "not_subscribed",
"words": "Not emailed: the contact hasn't subscribed to email updates.",
"count": 120
},
{
"reason": "no_address",
"words": "Not emailed: the contact has no email address.",
"count": 18
}
],
"subscribed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"imports": 0,
"staff": 4
},
"address_changed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"staff": 2,
"api_key": 0,
"import": 0
},
"link_hosts": [
"maplestreetfoodbank.org"
],
"image_hosts": [
"maplestreetfoodbank.org"
],
"sending_domain": "mail.maplestreetfoodbank.org",
"missing_settings": [],
"dmarc_warning": null,
"largest_email_bytes": 5120
},
"reviewed_at": "2026-10-13T14:00:00Z",
"reviewed_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"approved_at": "2026-10-13T14:05:00Z",
"approved_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"scheduled_for": "2026-10-13T14:15:00Z",
"started_at": "2026-10-13T14:15:00Z",
"completed_at": null,
"paused_at": "2026-10-13T14:30:00Z",
"pause_reason": "Paused by staff.",
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-13T14:30:00Z",
"deleted_at": null,
"deleted_by": null
}Resume a paused or canceled send
POST /v1/email-sends/{id}/resume
A signed-in admin with a fresh second step (as approve). A canceled send's resume re-queues exactly the messages its cancel suppressed (matched in SQL by the cancel's time); each is checked again before it goes.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200Going out again.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "sending",
"content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
"members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"review": {
"list_name": "Lapsed shelter donors",
"list_version": 3,
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"member_count": 1240,
"eligible_count": 1102,
"skipped": [
{
"reason": "not_subscribed",
"words": "Not emailed: the contact hasn't subscribed to email updates.",
"count": 120
},
{
"reason": "no_address",
"words": "Not emailed: the contact has no email address.",
"count": 18
}
],
"subscribed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"imports": 0,
"staff": 4
},
"address_changed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"staff": 2,
"api_key": 0,
"import": 0
},
"link_hosts": [
"maplestreetfoodbank.org"
],
"image_hosts": [
"maplestreetfoodbank.org"
],
"sending_domain": "mail.maplestreetfoodbank.org",
"missing_settings": [],
"dmarc_warning": null,
"largest_email_bytes": 5120
},
"reviewed_at": "2026-10-13T14:00:00Z",
"reviewed_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"approved_at": "2026-10-13T14:05:00Z",
"approved_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"scheduled_for": "2026-10-13T14:15:00Z",
"started_at": "2026-10-13T14:15:00Z",
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": null,
"canceled_by": null,
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-13T14:15:00Z",
"deleted_at": null,
"deleted_by": null
}Cancel a send
POST /v1/email-sends/{id}/cancel
A signed-in admin with a fresh second step, confirmed. Every message not yet gone is suppressed and expansion stops; a batch already at Postmark is recorded as it went. Resume undoes it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (EmailSendCancel)
| Name | Type | Required | Description |
|---|---|---|---|
confirm | one of: true | Yes | Canceling stops every email of this send that hasn't gone yet; resume undoes it. |
Example request
{
"confirm": true
}Responses
200Canceled.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"name": "Spring newsletter 2026",
"topic": "newsletter",
"content": {
"subject": [
{
"type": "merge",
"field": "first_name"
},
{
"type": "text",
"text": ", spring at the pantry"
}
],
"preheader": [
{
"type": "text",
"text": "What your gifts did this winter"
}
],
"blocks": [
{
"type": "heading",
"runs": [
{
"type": "text",
"text": "Spring at the pantry"
}
]
},
{
"type": "text",
"runs": [
{
"type": "text",
"text": "Thank you for "
},
{
"type": "merge",
"field": "last_gift_amount",
"fallback": "your support"
},
{
"type": "text",
"text": ". Read "
},
{
"type": "text",
"text": "the whole story",
"link": "https://maplestreetfoodbank.org/spring"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "image",
"src": "https://maplestreetfoodbank.org/img/volunteers.jpg",
"alt": "Volunteers sorting cans",
"width": 600
},
{
"type": "donation_link",
"label": "Give again",
"donation_form_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6e10",
"audience": "donors"
}
]
},
"render_version": 1,
"template_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8a01",
"saved_list_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7c01",
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d07",
"appeal_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d08",
"household_option": "per_person",
"track_clicks": false,
"attribution_code": "q4m2x7k3p9ab",
"status": "canceled",
"content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
"members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"review": {
"list_name": "Lapsed shelter donors",
"list_version": 3,
"snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
"member_count": 1240,
"eligible_count": 1102,
"skipped": [
{
"reason": "not_subscribed",
"words": "Not emailed: the contact hasn't subscribed to email updates.",
"count": 120
},
{
"reason": "no_address",
"words": "Not emailed: the contact has no email address.",
"count": 18
}
],
"subscribed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"imports": 0,
"staff": 4
},
"address_changed_since_last_send": {
"since": "2026-09-01T15:00:00Z",
"staff": 2,
"api_key": 0,
"import": 0
},
"link_hosts": [
"maplestreetfoodbank.org"
],
"image_hosts": [
"maplestreetfoodbank.org"
],
"sending_domain": "mail.maplestreetfoodbank.org",
"missing_settings": [],
"dmarc_warning": null,
"largest_email_bytes": 5120
},
"reviewed_at": "2026-10-13T14:00:00Z",
"reviewed_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"approved_at": "2026-10-13T14:05:00Z",
"approved_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"scheduled_for": "2026-10-13T14:15:00Z",
"started_at": "2026-10-13T14:15:00Z",
"completed_at": null,
"paused_at": null,
"pause_reason": null,
"resumed_at": null,
"canceled_at": "2026-10-13T14:31:00Z",
"canceled_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"created_by": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d02",
"api_key_id": null,
"created_at": "2026-10-12T16:00:00Z",
"updated_at": "2026-10-13T14:31:00Z",
"deleted_at": null,
"deleted_by": null
}A send's results
GET /v1/email-sends/{id}/results
Read access. Counts per status and reason, clicks per link per day (clicks, not people), gifts through the donation link (later charges of monthly gifts started there included) and gifts from the recipients within days of the start, labelled as such. Per recipient: GET /v1/emails?email_send_id=.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | |
days | integer | No |
Responses
200The results.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"email_send_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c8b01",
"status": "sent",
"list_size": 1240,
"skipped": [
{
"reason": "Not emailed: the contact hasn't subscribed to email updates.",
"count": 120
}
],
"queued": 0,
"sent": 1102,
"delivered": 1090,
"bounced": 6,
"failed": 0,
"unsubscribed": 3,
"complained": 0,
"clicks": [
{
"link_index": 0,
"target": "https://maplestreetfoodbank.org/spring",
"day": "2026-10-13",
"clicks": 84
}
],
"clicks_note": "Clicks, not people: one person can click more than once, and link scanners' clicks are included.",
"gifts_through_link": {
"count": 12,
"total_cents": 145000
},
"gifts_from_recipients": {
"count": 31,
"total_cents": 402500,
"days": 30,
"window": {
"from": "2026-10-13",
"to": "2026-11-11"
},
"label": "Gifts from people who got this email, within 30 days. Some would have come anyway."
}
}Inbound email
BCC capture (ADR-014). Staff BCC their organization's secret address on email to donors, and the email is filed to the timelines of the contacts it was sent to. Only text is kept; attachments are never stored. Mail that matches no contact, or whose sender could not be authenticated, waits in the inbox to be filed or discarded; so does all captured mail while automatic filing is off on the deployment (the default).
Replace the BCC capture address
POST /v1/email-settings/bcc-address/rotate
Admin only. Gives the organization a new secret BCC capture address at once; mail sent to the old address is no longer captured. Use it when the address has leaked, for example when it was put in To or Cc by mistake. Audited, without the address.
Responses
200The email settings with the new `bcc_address`.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"email_from_name": null,
"email_from_local_part": "giving",
"email_reply_to": "office@maplestreetfoodbank.org",
"from_name": "Maple Street Food Bank",
"from_address": "giving@maplestreetfoodbank.org",
"bcc_address": "inbound+k7m2q4x7w3p5r6t2v3n4b7c3@capture.operationcharity.org",
"sending_domain": {
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d64",
"domain": "maplestreetfoodbank.org",
"status": "verified",
"ownership_verified": true,
"dkim_verified": true,
"return_path_verified": true,
"verified_at": "2026-10-06T15:10:00Z",
"dns_records": [
{
"type": "TXT",
"purpose": "ownership",
"host": "_operationcharity.maplestreetfoodbank.org",
"value": "oc-verify=q3v7m2k4x6p5r2t7w3n4b6c5",
"verified": true
},
{
"type": "TXT",
"purpose": "dkim",
"host": "20261006150000pm._domainkey.maplestreetfoodbank.org",
"value": "k=rsa;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwXq3Vnq0Lr6m",
"verified": true
},
{
"type": "CNAME",
"purpose": "return_path",
"host": "pm-bounces.maplestreetfoodbank.org",
"value": "pm.mtasv.net",
"verified": true
}
],
"created_at": "2026-10-06T15:00:00Z",
"updated_at": "2026-10-06T15:10:00Z",
"deleted_at": null,
"deleted_by": null
},
"sending_today": {
"sent": 42,
"limit": 5000,
"resets_at": "2026-10-13T00:00:00Z"
},
"configured": {
"sending": true,
"inbound": true,
"inbound_auto_file": true,
"domains": true
}
}List captured email
GET /v1/inbound-emails
BCC-captured email; status=unmatched,needs_review is the inbox waiting to be filed. Sorted by received_at descending, then id, across every status asked for. Discarded email is excluded unless include_deleted=true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–200. |
cursor | string | No | Opaque cursor from a previous response's |
include_deleted | boolean | No | Admin only. When true, soft-deleted records ( |
status | list of one of: filed, unmatched, needs_review | No | Only these statuses: one, or a comma-separated list such as |
Responses
200A page of captured email.400Malformed request (bad cursor, unknown filter, unparseable body).401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"data": [
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d62",
"message_id": "<CAGXpo2WKfxHWZ5UFYCR3H_J9SNMG+5AXUovfEFL6DjWBJSyZaA@mail.gmail.com>",
"status": "unmatched",
"hold_reason": "no_contact_found",
"sender_user_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"from_address": "luis@maplestreetfoodbank.org",
"subject": "Shelter volunteer night",
"text_body": "Hi Sam,\n\nThanks for signing up for Thursday. Doors open at 6.\n\nLuis",
"recipients": [
"sam.rivera@example.net"
],
"attachments": [
{
"name": "volunteer-guide.pdf",
"type": "application/pdf",
"size": 48213
}
],
"auth_summary": "SPF passed for maplestreetfoodbank.org.",
"received_at": "2026-10-07T17:02:11Z",
"erased_at": null,
"communication_ids": [],
"contact_ids": [],
"created_at": "2026-10-07T17:02:11Z",
"updated_at": "2026-10-07T17:02:11Z",
"deleted_at": null,
"deleted_by": null
}
],
"next_cursor": null
}Get captured email
GET /v1/inbound-emails/{id}
A discarded email is still returned, with deleted_at set, so undo screens can show it. Lists hide it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The captured email.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d62",
"message_id": "<CAGXpo2WKfxHWZ5UFYCR3H_J9SNMG+5AXUovfEFL6DjWBJSyZaA@mail.gmail.com>",
"status": "unmatched",
"hold_reason": "no_contact_found",
"sender_user_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"from_address": "luis@maplestreetfoodbank.org",
"subject": "Shelter volunteer night",
"text_body": "Hi Sam,\n\nThanks for signing up for Thursday. Doors open at 6.\n\nLuis",
"recipients": [
"sam.rivera@example.net"
],
"attachments": [
{
"name": "volunteer-guide.pdf",
"type": "application/pdf",
"size": 48213
}
],
"auth_summary": "SPF passed for maplestreetfoodbank.org.",
"received_at": "2026-10-07T17:02:11Z",
"erased_at": null,
"communication_ids": [],
"contact_ids": [],
"created_at": "2026-10-07T17:02:11Z",
"updated_at": "2026-10-07T17:02:11Z",
"deleted_at": null,
"deleted_by": null
}Discard captured email
DELETE /v1/inbound-emails/{id}
Soft delete (ADR-005): discards the email from the inbox; undo with POST /v1/inbound-emails/{id}/restore. Timeline entries already filed from it stay. Unfiled email is still erased 30 days after it arrived, discarded or not. Emits no event.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
204Discarded.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Restore discarded email
POST /v1/inbound-emails/{id}/restore
Clears deleted_at and deleted_by. 409 if the email is not discarded. Emits no event.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Responses
200The restored email, with `deleted_at` null.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d62",
"message_id": "<CAGXpo2WKfxHWZ5UFYCR3H_J9SNMG+5AXUovfEFL6DjWBJSyZaA@mail.gmail.com>",
"status": "unmatched",
"hold_reason": "no_contact_found",
"sender_user_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"from_address": "luis@maplestreetfoodbank.org",
"subject": "Shelter volunteer night",
"text_body": "Hi Sam,\n\nThanks for signing up for Thursday. Doors open at 6.\n\nLuis",
"recipients": [
"sam.rivera@example.net"
],
"attachments": [
{
"name": "volunteer-guide.pdf",
"type": "application/pdf",
"size": 48213
}
],
"auth_summary": "SPF passed for maplestreetfoodbank.org.",
"received_at": "2026-10-07T17:02:11Z",
"erased_at": null,
"communication_ids": [],
"contact_ids": [],
"created_at": "2026-10-07T17:02:11Z",
"updated_at": "2026-10-07T17:02:11Z",
"deleted_at": null,
"deleted_by": null
}File captured email to contacts
POST /v1/inbound-emails/{id}/file
Files an unmatched or needs_review email to these contacts' timelines: one communication for each contact (channel email, direction outbound, logged by the user who sent it, dated when it arrived), each emitting communication.created; the email becomes filed. Audited with who filed it. 409 when it is filed already, discarded (restore it first) or erased; 422 for a contact that does not exist or is deleted, or one listed twice.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes |
Request body (InboundEmailFile)
| Name | Type | Required | Description |
|---|---|---|---|
contact_ids | list of string (uuid) | Yes | Live contacts to file the email to, each once. |
Example request
{
"contact_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
]
}Responses
200The filed email, with the timeline entries it created.401Missing, invalid, expired or revoked credential.403The credential is valid but lacks the scope or role for this operation (`forbidden`), or it is a session that has not completed two-step sign-in while the member's organization requires it or the member has an authenticator app set up (`two-step-required`, ADR-017): sign in with the code from the authenticator app, or set one up, and try again. API keys never get `two-step-required`.404No such record in this organization.409Uniqueness or state conflict, or an Idempotency-Key reused with a different body.422The body parsed but failed validation.429Rate limit exceeded.500Something failed on our side. The request can be retried; nothing was written.
Example response (200)
{
"id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d62",
"message_id": "<CAGXpo2WKfxHWZ5UFYCR3H_J9SNMG+5AXUovfEFL6DjWBJSyZaA@mail.gmail.com>",
"status": "filed",
"hold_reason": null,
"sender_user_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03",
"from_address": "luis@maplestreetfoodbank.org",
"subject": "Shelter volunteer night",
"text_body": "Hi Sam,\n\nThanks for signing up for Thursday. Doors open at 6.\n\nLuis",
"recipients": [
"sam.rivera@example.net"
],
"attachments": [
{
"name": "volunteer-guide.pdf",
"type": "application/pdf",
"size": 48213
}
],
"auth_summary": "SPF passed for maplestreetfoodbank.org.",
"received_at": "2026-10-07T17:02:11Z",
"erased_at": null,
"communication_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d67"
],
"contact_ids": [
"3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06"
],
"created_at": "2026-10-07T17:02:11Z",
"updated_at": "2026-10-08T09:30:00Z",
"deleted_at": null,
"deleted_by": null
}