Operation Charity API

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

  • 200 The API is serving requests.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Check that the API is up: example response
{
  "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

  • 200 The organization.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get the current organization: example response
{
  "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)

Update the current organization request body
NameTypeRequiredDescription
namestringNo

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).

einstring or nullNo
timezonestringNo
fiscal_year_start_monthintegerNo
mailing_addressstring or nullNo
receipt_signature_namestring or nullNo
receipt_signature_titlestring or nullNo
receipt_footerstring or nullNo
intangible_religious_benefitsbooleanNo
receipt_logo_urlstring or nullNo

Must start with https://.

require_two_stepbooleanNo

Only a signed-in admin whose session has completed two-step sign-in (aal2) may change it; anyone else, an API key of any scope included, gets 403 for this field (ADR-017). Audited like every organization change.

drafter_cannot_approvebooleanNo

Only a signed-in admin; an API key gets 403 for this field (ADR-023).

Example request
Update the current organization: example request
{
  "name": "Maple Street Food Bank",
  "fiscal_year_start_month": 7
}

Responses

  • 200 The updated organization.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update the current organization: example response
{
  "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

List users parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

roleone of: admin, staff, read_onlyNo
qstringNo

Matches name or email.

Responses

  • 200 A page of users.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List users: example response
{
  "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

  • 200 The caller.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get the caller: example response
{
  "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)

Invite a user request body
NameTypeRequiredDescription
emailstring (email)Yes
full_namestringYes
roleone of: admin, staff, read_onlyNo
Example request
Invite a user: example request
{
  "email": "amara@maplestreetfoodbank.org",
  "full_name": "Amara Okafor",
  "role": "staff"
}

Responses

  • 201 The invited user, with the invitation queued.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Invite a user: example response
{
  "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

Get a user parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The user.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a user: example response
{
  "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

Change a user's role parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (UserRoleChange)

Change a user's role request body
NameTypeRequiredDescription
roleone of: admin, staff, read_onlyYes
Example request
Change a user's role: example request
{
  "role": "admin"
}

Responses

  • 200 The updated user.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Change a user's role: example response
{
  "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

Deactivate a user parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The deactivated user.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Deactivate a user: example response
{
  "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

Reactivate a user parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The reactivated user.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Reactivate a user: example response
{
  "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

Send an invitation again parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 202 The user, with the new invitation queued.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (202)
Send an invitation again: example response
{
  "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

List contacts parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

qstringNo

Matches name, email or phone.

kindone of: person, organizationNo
household_idstring (uuid)No
emailstringNo

Exact match, case-insensitive.

do_not_contactbooleanNo
updated_sincestring (date-time)No

Only records updated at or after this timestamp (UTC).

idslist 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.

taglist 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_idstring (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.

snapshot_idstring (uuid)No

Only the contacts this snapshot froze (ADR-020), ANDed with the other filters. Members deleted since are left out unless include_deleted=true, so the rows can be fewer than its member_count. Never with saved_list_id.

includelist of one of: giving, totalNo

A comma-separated list of giving and total (ADR-020). giving adds giving to each contact on the page (ContactGiving, ADR-016). total adds total, how many contacts the whole query holds: one count(*) over the same condition the pages come from, so a saved list's member_count equals it and the rows paged to the end for the same data. Read access is enough. Any other value is a 400.

Responses

  • 200 A page of contacts.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 The 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.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Working 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)
List contacts: example response
{
  "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)

Create a contact request body
NameTypeRequiredDescription
address_line1string or nullNo
address_line2string or nullNo
citystring or nullNo
regionstring or nullNo

State or province.

postal_codestring or nullNo
countrystring or nullNo
kindone of: person, organizationNo
first_namestring or nullNo
last_namestring or nullNo
org_namestring or nullNo

Required when kind is organization.

emailstring (email) or nullNo
phonestring or nullNo
household_idstring (uuid) or nullNo
employerstring or nullNo
do_not_contactbooleanNo

Setting it is recorded as a consent event (ADR-024). Clearing it needs a signed-in user and do_not_contact_reason; an API key gets 403.

email_opt_inbooleanNo

Create and update only tighten (ADR-024): true is 422 here; subscribe a contact with POST /v1/contacts/{id}/email-subscription. false on a subscribed contact unsubscribes them, recorded as a consent event, and only their own confirmed sign-up subscribes them again.

email_appeals_opt_outbooleanNo

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_outbooleanNo

The person opted out of newsletters (ADR-023). Can be set here; clearing it is 422.

do_not_contact_reasonstringNo

Update only: why do-not-contact is being cleared (required to clear it); kept in the consent log.

sms_opt_inbooleanNo
sourcestring or nullNo

Where the record came from (import, form, referral, public-record).

source_refstring or nullNo

Import job id, form id or URL.

consentedbooleanNo

Engine gate. Contacts found in public records stay false until they opt in.

custom_fieldsobjectNo

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 (GET /v1/custom-field-definitions, archived fields included) whose value it adds or changes: a create for every defined key it includes, an update for those whose value differs from the stored one compared as JSON. Each such value must be of its field's type or null - text a string of at most 2,000 characters, number a JSON number, date a real date written YYYY-MM-DD, yes_no true or false, choice one of the field's choices exactly - and anything else is a 422 whose pointer names the key (/custom_fields/t_shirt_size). A value equal to the stored one passes even when it does not fit, and stays on the field's report of values that don't fit. A key with no definition (kept by the importer) is stored as sent. A write that would leave the object over 64 KB (65,536 bytes as JSON) and larger than it was is a 422 pointing at /custom_fields; an object already over 64 KB (an import is not held to the cap) can still be written the same size or smaller, so clearing or shortening a value always works. A yes/no counts the same (4 bytes) whichever it is, so changing one never grows the object. What changes for existing clients: a write that adds or changes a defined key's value with the wrong type, or that grows custom fields past 64 KB, now gets 422; nothing else changes.

Example request
Create a contact: 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

  • 201 The new contact.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a contact: example response
{
  "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

Get a contact parameters
NameTypeRequiredDescription
idstring (uuid)Yes
includelist of one of: giving, email_consentNo

A comma-separated list of giving and email_consent. giving adds giving (ContactGiving, ADR-016): lifetime giving, the number of gifts, and the first, last and largest gift, from the money library by the C5 reports' rules, hard credit only. email_consent adds email_consent (EmailConsentState, F1): the contact's standing with appeals and newsletters as ADR-024's rules decide it. Read access is enough. Without include the fields are absent and the response is unchanged. Any other value is a 400.

Responses

  • 200 The contact.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a contact: example response
{
  "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

Update a contact parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (ContactUpdate)

Update a contact request body
NameTypeRequiredDescription
address_line1string or nullNo
address_line2string or nullNo
citystring or nullNo
regionstring or nullNo

State or province.

postal_codestring or nullNo
countrystring or nullNo
kindone of: person, organizationNo
first_namestring or nullNo
last_namestring or nullNo
org_namestring or nullNo

Required when kind is organization.

emailstring (email) or nullNo
phonestring or nullNo
household_idstring (uuid) or nullNo
employerstring or nullNo
do_not_contactbooleanNo

Setting it is recorded as a consent event (ADR-024). Clearing it needs a signed-in user and do_not_contact_reason; an API key gets 403.

email_opt_inbooleanNo

Create and update only tighten (ADR-024): true is 422 here; subscribe a contact with POST /v1/contacts/{id}/email-subscription. false on a subscribed contact unsubscribes them, recorded as a consent event, and only their own confirmed sign-up subscribes them again.

email_appeals_opt_outbooleanNo

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_outbooleanNo

The person opted out of newsletters (ADR-023). Can be set here; clearing it is 422.

do_not_contact_reasonstringNo

Update only: why do-not-contact is being cleared (required to clear it); kept in the consent log.

sms_opt_inbooleanNo
sourcestring or nullNo

Where the record came from (import, form, referral, public-record).

source_refstring or nullNo

Import job id, form id or URL.

consentedbooleanNo

Engine gate. Contacts found in public records stay false until they opt in.

custom_fieldsobjectNo

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 (GET /v1/custom-field-definitions, archived fields included) whose value it adds or changes: a create for every defined key it includes, an update for those whose value differs from the stored one compared as JSON. Each such value must be of its field's type or null - text a string of at most 2,000 characters, number a JSON number, date a real date written YYYY-MM-DD, yes_no true or false, choice one of the field's choices exactly - and anything else is a 422 whose pointer names the key (/custom_fields/t_shirt_size). A value equal to the stored one passes even when it does not fit, and stays on the field's report of values that don't fit. A key with no definition (kept by the importer) is stored as sent. A write that would leave the object over 64 KB (65,536 bytes as JSON) and larger than it was is a 422 pointing at /custom_fields; an object already over 64 KB (an import is not held to the cap) can still be written the same size or smaller, so clearing or shortening a value always works. A yes/no counts the same (4 bytes) whichever it is, so changing one never grows the object. What changes for existing clients: a write that adds or changes a defined key's value with the wrong type, or that grows custom fields past 64 KB, now gets 422; nothing else changes.

Example request
Update a contact: example request
{
  "email_opt_in": false,
  "custom_fields": {
    "volunteer": true,
    "t_shirt_size": "M"
  }
}

Responses

  • 200 The updated contact.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a contact: example response
{
  "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

Delete a contact parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted contact parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored contact, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted contact: example response
{
  "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

Get a contact's timeline parameters
NameTypeRequiredDescription
idstring (uuid)Yes
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

kindlist of one of: gift, adjustment, soft_credit, receipt, communication, taskNo

Only these kinds: a comma-separated list of gift, adjustment, soft_credit, receipt, communication, task. Omit for all. 400 for an unknown kind.

Responses

  • 200 A page of the contact's timeline, newest first.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a contact's timeline: example response
{
  "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

Add or remove tags on a contact parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (ContactTagsUpdate)

Add or remove tags on a contact request body
NameTypeRequiredDescription
addlist of string (uuid)No

Live tags of the organization.

removelist of string (uuid)No

Live tags of the organization.

Example request
Add or remove tags on a contact: example request
{
  "add": [
    "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01"
  ]
}

Responses

  • 200 The contact with its tags.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Add or remove tags on a contact: example response
{
  "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

Tag, untag, delete or restore many contacts parameters
NameTypeRequiredDescription
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (ContactBulkRequest)

Tag, untag, delete or restore many contacts request body
NameTypeRequiredDescription
actionone of: add_tag, remove_tag, delete, restoreYes

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_idslist of string (uuid)No

Up to 500 contacts of the organization. Ids are read in any case and answered in lower case, results[].contact_id and tag_id included, so an id repeated in another case is a repeat.

tag_idstring (uuid)No

The tag, for add_tag and remove_tag (a live tag of the organization); refused with delete and restore.

snapshot_idstring (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.

cursorstringNo

Continues a run: the next_cursor of its last call. It names the run and is taken only from whoever started it while the run is running; where to go on is the run's own progress.

Example request
Tag, untag, delete or restore many contacts: 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

  • 200 Each contact's outcome and the counts.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Tag, untag, delete or restore many contacts: example response
{
  "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)

Export contacts as CSV request body
NameTypeRequiredDescription
formatone of: csvYes

csv: RFC 4180, UTF-8, CRLF line endings, a header row, amounts as plain decimal dollars.

idslist of string (uuid)No

Only these contacts, in the list's order; ids that match nothing are skipped.

include_deletedbooleanNo

Admin only, as on the list: include soft-deleted contacts.

qstringNo

Matches name, email or phone. The audit log keeps its length, never its text.

kindone of: person, organizationNo
household_idstring (uuid)No
emailstringNo

Exact match, case-insensitive. The audit log keeps its length, never the address.

do_not_contactbooleanNo
updated_sincestring (date-time)No
taglist of string (uuid)No

Contacts with any of these tags.

saved_list_idstring (uuid)No

A saved list's members now (ADR-020), as GET /v1/contacts?saved_list_id= gives them, with the other filters; never with ids or snapshot_id. 409 while the list needs fixing.

snapshot_idstring (uuid)No

A snapshot's members (ADR-020), deleted ones left out unless include_deleted; never with ids or saved_list_id.

Example request
Export contacts as CSV: example request
{
  "format": "csv",
  "ids": [
    "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
    "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d81"
  ]
}

Responses

  • 200 The contacts.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 The 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.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Working 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)
Export contacts as CSV: example response
"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

Find contacts by a filter parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

includelist of one of: giving, totalNo

A comma-separated list of giving and total (ADR-020). giving adds giving to each contact on the page (ContactGiving, ADR-016). total adds total, how many contacts the whole query holds: one count(*) over the same condition the pages come from, so a saved list's member_count equals it and the rows paged to the end for the same data. Read access is enough. Any other value is a 400.

Request body (ContactSearchRequest)

Find contacts by a filter request body
NameTypeRequiredDescription
filterSavedListFilterYes

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 not: true. At most 20 conditions in all (leaves in groups counted), 50 ids or values in a condition, and 64 KB. Each problem is a 422 with its own pointer (/filter/conditions/2/fund_ids/0): a property the grammar does not know, at any depth; no conditions; a wrong type or a limit passed; from after to, or a minimum above a maximum; a tag, fund, campaign or appeal that is not live in the organization, or an undefined custom field; a value its field's type does not allow. version is the grammar's own (1), so a later grammar can migrate stored filters.

Example request
Find contacts by a filter: 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

  • 200 A page of the contacts the filter holds.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Working 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)
Find contacts by a filter: example response
{
  "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

Subscribe a contact to email updates parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailSubscriptionRequest)

Subscribe a contact to email updates request body
NameTypeRequiredDescription
agreed_howone of: in_person, phone, paper_formNo

Signed-in staff: how the person agreed (required). An API key never sends it.

notestring or nullNo
Example request
Subscribe a contact to email updates: example request
{
  "agreed_how": "in_person",
  "note": "Signed up at the spring open house"
}

Responses

  • 200 Subscribed (or already was).
  • 202 A confirmation email is on its way; nothing changes until the person confirms.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Subscribe a contact to email updates: example response
{
  "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

List consent events parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

contact_idstring (uuid)No

Responses

  • 200 A page of events.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List consent events: example response
{
  "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
}

Tags

The organization's own tags for contacts (ADR-018). Admins create, rename, recolor and delete them; staff tag contacts, one at a time or up to 500 at once; everyone can see them and filter contacts by them. Deleting a tag hides it and keeps its assignments, so restoring it puts it back on every contact it was on. Each tag's color is a design-system token name, so every tag the web app draws is readable. Tags emit tag.created, tag.updated and tag.deleted, and a restore emits tag.updated (ADR-001, ADR-005); a change to a contact's tags emits contact.updated.

List tags

GET /v1/tags

Read access. Sorted by name (case-insensitive), then id. Each tag says how many live contacts have it. Soft-deleted tags are excluded unless include_deleted=true (admin only).

Parameters

List tags parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

qstringNo

Part of the name, case-insensitive.

Responses

  • 200 A page of tags.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List tags: example response
{
  "data": [
    {
      "id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02",
      "name": "Board",
      "color": "blue",
      "contact_count": 7,
      "created_at": "2026-09-01T16:00:00Z",
      "updated_at": "2026-10-06T09:00:00Z",
      "deleted_at": null,
      "deleted_by": null
    },
    {
      "id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
      "name": "Volunteer",
      "color": "green",
      "contact_count": 23,
      "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 tag

POST /v1/tags

Admin only (a signed-in admin; API keys get 403, ADR-006). The name is trimmed and must be unique among live tags whatever the case (409 otherwise); the color defaults to gray. Emits tag.created.

Request body (TagCreate)

Create a tag request body
NameTypeRequiredDescription
namestringYes

Trimmed; one line of 1 to 50 characters.

colorone of: gray, teal, blue, violet, pink, red, orange, greenNo

A tag's color, as a design-system token name (--tag-<color>-bg behind --tag-<color>-ink in DESIGN.md), so every tag the web app draws passes the contrast test in light and dark. Mirrors the check constraint on tags.color.

Example request
Create a tag: example request
{
  "name": "Volunteer",
  "color": "green"
}

Responses

  • 201 The new tag.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a tag: example response
{
  "id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
  "name": "Volunteer",
  "color": "green",
  "contact_count": 0,
  "created_at": "2026-09-01T16:00:00Z",
  "updated_at": "2026-09-01T16:00:00Z",
  "deleted_at": null,
  "deleted_by": null
}

Get a tag

GET /v1/tags/{id}

A soft-deleted tag is still returned, with deleted_at set, so undo screens can show it.

Parameters

Get a tag parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The tag.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a tag: example response
{
  "id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
  "name": "Volunteer",
  "color": "green",
  "contact_count": 23,
  "created_at": "2026-09-01T16:00:00Z",
  "updated_at": "2026-09-01T16:00:00Z",
  "deleted_at": null,
  "deleted_by": null
}

Rename or recolor a tag

PATCH /v1/tags/{id}

Admin only. Every contact with the tag shows the new name and color at once. 409 when another live tag has the name. Emits tag.updated.

Parameters

Rename or recolor a tag parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (TagUpdate)

Rename or recolor a tag request body
NameTypeRequiredDescription
namestringNo

Trimmed; one line of 1 to 50 characters.

colorone of: gray, teal, blue, violet, pink, red, orange, greenNo

A tag's color, as a design-system token name (--tag-<color>-bg behind --tag-<color>-ink in DESIGN.md), so every tag the web app draws passes the contrast test in light and dark. Mirrors the check constraint on tags.color.

Example request
Rename or recolor a tag: example request
{
  "name": "Volunteers",
  "color": "teal"
}

Responses

  • 200 The updated tag.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Rename or recolor a tag: example response
{
  "id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a01",
  "name": "Volunteers",
  "color": "teal",
  "contact_count": 23,
  "created_at": "2026-09-01T16:00:00Z",
  "updated_at": "2026-10-05T14:30:00Z",
  "deleted_at": null,
  "deleted_by": null
}

Delete a tag

DELETE /v1/tags/{id}

Admin only. Soft delete (ADR-005): the tag leaves every contact's tags and matches nobody in the tag filter, and its assignments are kept, so POST /v1/tags/{id}/restore puts it back on every contact it was on. Emits tag.deleted with the row (deleted_at set).

Parameters

Delete a tag parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.

Restore a deleted tag

POST /v1/tags/{id}/restore

Admin only. Clears deleted_at and deleted_by; the tag is back on every contact that had it. 409 if the tag is not deleted, or if another live tag now has its name (rename one of them first). Emits tag.updated, as every restore does (ADR-005).

Parameters

Restore a deleted tag parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored tag, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted tag: example response
{
  "id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7a02",
  "name": "Board",
  "color": "blue",
  "contact_count": 7,
  "created_at": "2026-09-01T16:00:00Z",
  "updated_at": "2026-10-06T09:00:00Z",
  "deleted_at": null,
  "deleted_by": 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

List custom fields parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_archivedbooleanNo

When true, archived custom fields are listed too (archived_at set), each in its place in the order. Read access is enough.

Responses

  • 200 A page of custom fields.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List custom fields: example response
{
  "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)

Define a custom field request body
NameTypeRequiredDescription
keystringYes

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.

labelstringYes

Trimmed; one line.

typeone of: text, number, date, yes_no, choiceYes

What a custom field holds, and the JSON a contact's custom_fields takes for it: text a string (at most 2,000 characters), number a JSON number, date a real date written YYYY-MM-DD, yes_no true or false, choice one of the field's choices exactly. Mirrors the check constraint on custom_field_definitions.type.

choiceslist of stringNo

Required for a choice field and refused for any other: each choice one line of 1 to 100 characters, listed once whatever the case.

positionintegerNo

Where the field goes in the order; omit to put it last. The fields after it move down one.

Example request
Define a custom field: example request
{
  "key": "t_shirt_size",
  "label": "T-shirt size",
  "type": "choice",
  "choices": [
    "S",
    "M",
    "L",
    "XL"
  ]
}

Responses

  • 201 The new custom field.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Define a custom field: example response
{
  "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

Get a custom field parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The custom field.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a custom field: example response
{
  "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

Rename, reorder or retype a custom field parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (CustomFieldDefinitionUpdate)

Rename, reorder or retype a custom field request body
NameTypeRequiredDescription
labelstringNo

Trimmed; one line.

typeone of: text, number, date, yes_no, choiceNo

What a custom field holds, and the JSON a contact's custom_fields takes for it: text a string (at most 2,000 characters), number a JSON number, date a real date written YYYY-MM-DD, yes_no true or false, choice one of the field's choices exactly. Mirrors the check constraint on custom_field_definitions.type.

choiceslist of stringNo

A choice field's new list, in the order offered. Choices can be added and reordered; removing one that a live contact holds is a 409 naming it.

positionintegerNo

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
Rename, reorder or retype a custom field: example request
{
  "label": "Shirt size",
  "choices": [
    "XS",
    "S",
    "M",
    "L",
    "XL"
  ]
}

Responses

  • 200 The updated custom field.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Rename, reorder or retype a custom field: example response
{
  "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

Archive a custom field parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The archived custom field.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Archive a custom field: example response
{
  "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

Unarchive a custom field parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The custom field, with `archived_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Unarchive a custom field: example response
{
  "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

List the values that do not fit a custom field parameters
NameTypeRequiredDescription
idstring (uuid)Yes
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

Responses

  • 200 A page of contacts and their values.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List the values that do not fit a custom field: example response
{
  "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

List saved lists parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

qstringNo

Part of the name, case-insensitive.

Responses

  • 200 A page of saved lists.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List saved lists: example response
{
  "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)

Save a list request body
NameTypeRequiredDescription
namestringYes

Trimmed; one line.

descriptionstring or nullNo
filterSavedListFilterYes

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 not: true. At most 20 conditions in all (leaves in groups counted), 50 ids or values in a condition, and 64 KB. Each problem is a 422 with its own pointer (/filter/conditions/2/fund_ids/0): a property the grammar does not know, at any depth; no conditions; a wrong type or a limit passed; from after to, or a minimum above a maximum; a tag, fund, campaign or appeal that is not live in the organization, or an undefined custom field; a value its field's type does not allow. version is the grammar's own (1), so a later grammar can migrate stored filters.

Example request
Save a list: 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

  • 201 The saved list.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Save a list: example response
{
  "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

Get a saved list parameters
NameTypeRequiredDescription
idstring (uuid)Yes
includeone of: countNo

count adds member_count (ADR-020). Any other value is a 400.

Responses

  • 200 The saved list.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 The 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.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Working 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)
Get a saved list: example response
{
  "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

Rename or change a saved list parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (SavedListUpdate)

Rename or change a saved list request body
NameTypeRequiredDescription
namestringNo

Trimmed; one line.

descriptionstring or nullNo
filterSavedListFilterNo

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 not: true. At most 20 conditions in all (leaves in groups counted), 50 ids or values in a condition, and 64 KB. Each problem is a 422 with its own pointer (/filter/conditions/2/fund_ids/0): a property the grammar does not know, at any depth; no conditions; a wrong type or a limit passed; from after to, or a minimum above a maximum; a tag, fund, campaign or appeal that is not live in the organization, or an undefined custom field; a value its field's type does not allow. version is the grammar's own (1), so a later grammar can migrate stored filters.

Example request
Rename or change a saved list: example request
{
  "name": "Lapsed shelter donors (mail)"
}

Responses

  • 200 The updated list.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Rename or change a saved list: example response
{
  "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

Delete a saved list parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted saved list parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored list, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted saved list: example response
{
  "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

Freeze a saved list's members parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (SavedListSnapshotCreate)

Freeze a saved list's members request body
NameTypeRequiredDescription
namestringNo

Optional; trimmed, one line.

Example request
Freeze a saved list's members: example request
{
  "name": "November appeal"
}

Responses

  • 201 The snapshot.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 The 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.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Working 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)
Freeze a saved list's members: example response
{
  "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

List snapshots parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

saved_list_idstring (uuid)No

Responses

  • 200 A page of snapshots.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List snapshots: example response
{
  "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

Get a snapshot parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The snapshot.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a snapshot: example response
{
  "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

List bulk runs parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

snapshot_idstring (uuid)No

Responses

  • 200 A page of runs.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List bulk runs: example response
{
  "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

Get a bulk run parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The run.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a bulk run: example response
{
  "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

Undo a bulk run parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 What this call reversed, and the run.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Undo a bulk run: example response
{
  "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

List households parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

qstringNo

Matches the household name.

updated_sincestring (date-time)No

Only records updated at or after this timestamp (UTC).

Responses

  • 200 A page of households.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List households: example response
{
  "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)

Create a household request body
NameTypeRequiredDescription
address_line1string or nullNo
address_line2string or nullNo
citystring or nullNo
regionstring or nullNo

State or province.

postal_codestring or nullNo
countrystring or nullNo
namestringYes
Example request
Create a household: example request
{
  "name": "The Nakamura household",
  "address_line1": "1420 Spruce St",
  "city": "Denver",
  "region": "CO",
  "postal_code": "80205"
}

Responses

  • 201 The new household.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a household: example response
{
  "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

Get a household parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The household.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a household: example response
{
  "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

Update a household parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (HouseholdUpdate)

Update a household request body
NameTypeRequiredDescription
address_line1string or nullNo
address_line2string or nullNo
citystring or nullNo
regionstring or nullNo

State or province.

postal_codestring or nullNo
countrystring or nullNo
namestringNo
Example request
Update a household: example request
{
  "address_line1": "1420 Spruce St",
  "postal_code": "80205"
}

Responses

  • 200 The updated household.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a household: example response
{
  "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

Delete a household parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted household parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored household, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted household: example response
{
  "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

List contact relationships parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

contact_idstring (uuid)No

Relationships where this contact is on either side.

relationshipstringNo

Responses

  • 200 A page of relationships.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List contact relationships: example response
{
  "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)

Create a contact relationship request body
NameTypeRequiredDescription
from_contact_idstring (uuid)Yes
to_contact_idstring (uuid)Yes
relationshipstringYes
Example request
Create a contact relationship: example request
{
  "from_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
  "to_contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
  "relationship": "spouse"
}

Responses

  • 201 The new relationship.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a contact relationship: example response
{
  "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

Get a contact relationship parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The relationship.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a contact relationship: example response
{
  "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

Update a contact relationship parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (ContactRelationshipUpdate)

Update a contact relationship request body
NameTypeRequiredDescription
relationshipstringYes
Example request
Update a contact relationship: example request
{
  "relationship": "partner"
}

Responses

  • 200 The updated relationship.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a contact relationship: example response
{
  "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

Delete a contact relationship parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted contact relationship parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored contact relationship, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted contact relationship: example response
{
  "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

List funds parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

restrictionone of: unrestricted, purpose, time, perpetualNo
activebooleanNo

Responses

  • 200 A page of funds.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List funds: example response
{
  "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)

Create a fund request body
NameTypeRequiredDescription
namestringYes
codestring or nullNo
restrictionone of: unrestricted, purpose, time, perpetualNo
purposestring or nullNo
restriction_release_datestring (date) or nullNo
activebooleanNo
Example request
Create a fund: example request
{
  "name": "Winter Shelter Fund",
  "code": "SHELTER",
  "restriction": "purpose",
  "purpose": "Overnight shelter operations from November through March"
}

Responses

  • 201 The new fund.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a fund: example response
{
  "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

Get a fund parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The fund.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a fund: example response
{
  "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

Update a fund parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (FundUpdate)

Update a fund request body
NameTypeRequiredDescription
namestringNo
codestring or nullNo
restrictionone of: unrestricted, purpose, time, perpetualNo
purposestring or nullNo
restriction_release_datestring (date) or nullNo
activebooleanNo
Example request
Update a fund: example request
{
  "purpose": "Overnight shelter operations from November through March"
}

Responses

  • 200 The updated fund.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a fund: example response
{
  "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

Delete a fund parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Fund ledger parameters
NameTypeRequiredDescription
idstring (uuid)Yes
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

Responses

  • 200 A page of ledger lines.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Fund ledger: example response
{
  "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

Restore a deleted fund parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored fund, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted fund: example response
{
  "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

List campaigns parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

qstringNo

Matches the campaign name.

active_onstring (date)No

Campaigns whose date range includes this date.

Responses

  • 200 A page of campaigns.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List campaigns: example response
{
  "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)

Create a campaign request body
NameTypeRequiredDescription
namestringYes
goal_centsinteger (int64) or nullNo

Integer cents

starts_onstring (date) or nullNo
ends_onstring (date) or nullNo
default_fund_idstring (uuid) or nullNo
Example request
Create a campaign: 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

  • 201 The new campaign.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a campaign: example response
{
  "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

Get a campaign parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The campaign.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a campaign: example response
{
  "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

Update a campaign parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (CampaignUpdate)

Update a campaign request body
NameTypeRequiredDescription
namestringNo
goal_centsinteger (int64) or nullNo

Integer cents

starts_onstring (date) or nullNo
ends_onstring (date) or nullNo
default_fund_idstring (uuid) or nullNo
Example request
Update a campaign: example request
{
  "goal_cents": 5000000
}

Responses

  • 200 The updated campaign.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a campaign: example response
{
  "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

Delete a campaign parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted campaign parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored campaign, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted campaign: example response
{
  "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

List appeals parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

campaign_idstring (uuid)No
qstringNo

Matches the appeal name.

Responses

  • 200 A page of appeals.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List appeals: example response
{
  "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)

Create an appeal request body
NameTypeRequiredDescription
campaign_idstring (uuid) or nullNo
namestringYes
channelstring or nullNo
sent_onstring (date) or nullNo
cost_centsinteger (int64)No

Integer cents

Example request
Create an appeal: example request
{
  "campaign_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d10",
  "name": "September letter",
  "channel": "letter",
  "sent_on": "2026-09-28",
  "cost_cents": 84000
}

Responses

  • 201 The new appeal.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create an appeal: example response
{
  "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

Get an appeal parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The appeal.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get an appeal: example response
{
  "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

Update an appeal parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (AppealUpdate)

Update an appeal request body
NameTypeRequiredDescription
campaign_idstring (uuid) or nullNo
namestringNo
channelstring or nullNo
sent_onstring (date) or nullNo
cost_centsinteger (int64)No

Integer cents

Example request
Update an appeal: example request
{
  "cost_cents": 84000,
  "sent_on": "2026-09-28"
}

Responses

  • 200 The updated appeal.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update an appeal: example response
{
  "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

Delete an appeal parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted appeal parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored appeal, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted appeal: example response
{
  "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

List gifts parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

contact_idstring (uuid)No
fund_idstring (uuid)No
campaign_idstring (uuid)No
appeal_idstring (uuid)No
grant_idstring (uuid)No
date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

kindone of: one_time, recurring, pledge, pledge_payment, in_kind, grant_paymentNo
statusone of: posted, refunded, voidedNo
donation_form_idstring (uuid)No
recurring_donation_idstring (uuid)No
batch_idstring (uuid)No
email_send_idstring (uuid)No

Gifts through an email send's donation link (ADR-023): the checkouts its ref code started, and later charges of the monthly gifts started there.

Responses

  • 200 A page of gifts.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List gifts: example response
{
  "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

Record a gift parameters
NameTypeRequiredDescription
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (GiftCreate)

Record a gift request body
NameTypeRequiredDescription
contact_idstring (uuid)Yes
fund_idstring (uuid)Yes

Fund and restriction dimension. Required on every gift.

campaign_idstring (uuid) or nullNo
appeal_idstring (uuid) or nullNo
grant_idstring (uuid) or nullNo

Set on grant_payment gifts.

kindone of: one_time, recurring, pledge, pledge_payment, in_kind, grant_paymentNo
amount_centsinteger (int64) or nullNo

Integer cents. Required unless kind is in_kind; null or omitted for in_kind.

fair_market_value_centsinteger (int64)No

Integer cents of goods or services the donor received (quid pro quo). 0 for a pure donation.

fee_covered_centsinteger (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_onstring (date)Yes
contribution_datestring (date) or nullNo

Postmark, card charge or transfer date. Defaults to received_on.

payment_methodstring or nullNo

check, card, ach, cash, stock or in_kind.

external_refstring or nullNo

Stripe payment id or check number. When payment_method is check (in any letter case) it is the check's number and nothing else: 1 to 10 digits (^[0-9]{1,10}$), the number at the top right of the check, never the line along the bottom (it holds the donor's bank routing and account numbers, which are never stored); anything else is 422 on /external_ref. A gift in a deposit batch holds only such a number, or none (cash), and is 422 on /external_ref otherwise.

in_kind_descriptionstring or nullNo

Staff description of donated property.

property_descriptionstring or nullNo

What the receipt prints for an in-kind gift (described, never valued). Defaults to in_kind_description.

book_fair_value_centsinteger (int64) or nullNo

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_methodstring or nullNo

Internal only: how the book value was set (for example comparable sales, appraisal, donor estimate). In-kind gifts only.

nonfinancial_categorystring or nullNo

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_idstring (uuid) or nullNo

The pledge this pledge_payment fulfils.

tribute_typestring or nullNo

in_honor_of or in_memory_of.

tribute_namestring or nullNo
notify_contact_idstring (uuid) or nullNo
notesstring or nullNo
batch_idstring (uuid) or nullNo

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 /batch_id; a closed batch is 409. A batch is a bank deposit, so its gifts are checks and cash: payment_method must be check or cash (422 on /payment_method), and received_on on or before the batch's deposit date (422 on /received_on). Set only here; it never changes.

Example request
Record a gift: 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

  • 201 The recorded gift.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Record a gift: example response
{
  "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

Get a gift parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The gift.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a gift: example response
{
  "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

Update an in-kind gift's valuation parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (GiftValuationUpdate)

Update an in-kind gift's valuation request body
NameTypeRequiredDescription
book_fair_value_centsinteger (int64) or nullNo

Integer cents. Internal only; never printed or totalled.

valuation_methodstring or nullNo
nonfinancial_categorystring or nullNo
property_descriptionstringNo

What the receipt prints. Cannot change once a receipt was issued.

Example request
Update an in-kind gift's valuation: example request
{
  "book_fair_value_cents": 42000,
  "valuation_method": "independent appraisal"
}

Responses

  • 200 The gift with its new valuation.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update an in-kind gift's valuation: example response
{
  "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

List gift adjustments parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

gift_idstring (uuid)No
kindone of: refund, void, reallocate, correct_amount, release_restrictionNo
date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

Responses

  • 200 A page of adjustments.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List gift adjustments: example response
{
  "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

Record a gift adjustment parameters
NameTypeRequiredDescription
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (GiftAdjustmentCreate)

Record a gift adjustment request body
NameTypeRequiredDescription
gift_idstring (uuid)Yes
kindone of: refund, void, reallocate, correct_amount, release_restrictionYes

Mirrors the check constraint on gift_adjustments.kind.

amount_centsinteger (int64)Yes

Integer cents, signed.

from_fund_idstring (uuid) or nullNo
to_fund_idstring (uuid) or nullNo
effective_onstring (date)Yes
reasonstringYes
exception_reasonstring or nullNo

release_restriction only: why this release may take the fund below zero.

consent_referencestring or nullNo

reallocate only: required when moving money out of a restricted fund.

Example request
Record a gift adjustment: 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

  • 201 The recorded adjustment.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Record a gift adjustment: example response
{
  "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

Get a gift adjustment parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The adjustment.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a gift adjustment: example response
{
  "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

List deposit batches parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

statusone of: open, closedNo
date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

Responses

  • 200 A page of batches.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List deposit batches: example response
{
  "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

Start a deposit batch parameters
NameTypeRequiredDescription
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (GiftBatchCreate)

Start a deposit batch request body
NameTypeRequiredDescription
namestringYes

Leading and trailing spaces are dropped.

deposit_datestring (date)Yes
expected_countintegerYes
expected_total_centsinteger (int64)Yes

Integer cents

notesstring or nullNo
Example request
Start a deposit batch: 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

  • 201 The new batch, open and empty.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Start a deposit batch: example response
{
  "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

Get a deposit batch parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The batch.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a deposit batch: example response
{
  "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

Update an open deposit batch parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (GiftBatchUpdate)

Update an open deposit batch request body
NameTypeRequiredDescription
namestringNo
deposit_datestring (date)No
expected_countintegerNo
expected_total_centsinteger (int64)No

Integer cents

notesstring or nullNo
Example request
Update an open deposit batch: example request
{
  "expected_total_cents": 22500
}

Responses

  • 200 The updated batch.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update an open deposit batch: example response
{
  "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

Delete an empty deposit batch parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted deposit batch parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored batch, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted deposit batch: example response
{
  "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

Close a deposit batch that balances parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The closed batch.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 The 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.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Close a deposit batch that balances: example response
{
  "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

Reopen a closed deposit batch parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (GiftBatchReopen)

Reopen a closed deposit batch request body
NameTypeRequiredDescription
reasonstringYes

Why the batch is reopened. Kept on the batch (reopen_reason) and in the audit log with who reopened it and when.

Example request
Reopen a closed deposit batch: example request
{
  "reason": "The bank's deposit receipt shows four checks; one was left out of the batch."
}

Responses

  • 200 The reopened batch.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Reopen a closed deposit batch: example response
{
  "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

List the gifts in a deposit batch parameters
NameTypeRequiredDescription
idstring (uuid)Yes
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

Responses

  • 200 A page of entries.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List the gifts in a deposit batch: example response
{
  "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

Download a closed batch's deposit slip parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The deposit slip.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Download a closed batch's deposit slip: example response
"%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

List soft credits parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

gift_idstring (uuid)No
contact_idstring (uuid)No

Responses

  • 200 A page of soft credits.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List soft credits: example response
{
  "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)

Create a soft credit request body
NameTypeRequiredDescription
gift_idstring (uuid)Yes
contact_idstring (uuid)Yes
amount_centsinteger (int64)Yes

Integer cents

roleone of: household_member, daf_advisor, matched_donor, solicitor, influencer, honoree, memorial, board_or_foundation_officer, peer_to_peer_fundraiser, organization_affiliationYes

Why a contact is credited for someone else's gift (ADR-008). Soft credits never count in receipts, totals, balances or retention.

reasonstring or nullNo
Example request
Create a soft credit: example request
{
  "gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
  "contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06",
  "amount_cents": 5000,
  "role": "household_member"
}

Responses

  • 201 The new soft credit.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a soft credit: example response
{
  "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

Get a soft credit parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The soft credit.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a soft credit: example response
{
  "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

Update a soft credit parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (SoftCreditUpdate)

Update a soft credit request body
NameTypeRequiredDescription
contact_idstring (uuid)No
amount_centsinteger (int64)No

Integer cents

roleone of: household_member, daf_advisor, matched_donor, solicitor, influencer, honoree, memorial, board_or_foundation_officer, peer_to_peer_fundraiser, organization_affiliationNo

Why a contact is credited for someone else's gift (ADR-008). Soft credits never count in receipts, totals, balances or retention.

reasonstring or nullNo
Example request
Update a soft credit: example request
{
  "role": "household_member",
  "reason": "Spouse; gave from the joint account."
}

Responses

  • 200 The updated soft credit.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a soft credit: example response
{
  "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

Delete a soft credit parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted soft credit parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored soft credit, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted soft credit: example response
{
  "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

List grants parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

statusone of: prospect, applied, awarded, declined, closedNo
funder_contact_idstring (uuid)No
fund_idstring (uuid)No

Responses

  • 200 A page of grants.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List grants: example response
{
  "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)

Create a grant request body
NameTypeRequiredDescription
funder_contact_idstring (uuid) or nullNo
fund_idstring (uuid) or nullNo

Fund that receives the grant payments.

namestringYes
statusone of: prospect, applied, awarded, declined, closedNo
awarded_centsinteger (int64)No

Integer cents

indirect_rate_bpsintegerNo

Indirect cost rate in basis points of modified total direct costs. 1500 = the 15% de minimis rate (2 CFR 200.414(f)).

period_startstring (date) or nullNo
period_endstring (date) or nullNo
allowed_usesstring or nullNo
report_due_onstring (date) or nullNo
next_payment_due_onstring (date) or nullNo
notesstring or nullNo
Example request
Create a grant: 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

  • 201 The new grant.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a grant: example response
{
  "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

Get a grant parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The grant.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a grant: example response
{
  "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

Update a grant parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (GrantUpdate)

Update a grant request body
NameTypeRequiredDescription
funder_contact_idstring (uuid) or nullNo
fund_idstring (uuid) or nullNo

Fund that receives the grant payments.

namestringNo
statusone of: prospect, applied, awarded, declined, closedNo
awarded_centsinteger (int64)No

Integer cents

indirect_rate_bpsintegerNo

Indirect cost rate in basis points of modified total direct costs. 1500 = the 15% de minimis rate (2 CFR 200.414(f)).

period_startstring (date) or nullNo
period_endstring (date) or nullNo
allowed_usesstring or nullNo
report_due_onstring (date) or nullNo
next_payment_due_onstring (date) or nullNo
notesstring or nullNo
Example request
Update a grant: example request
{
  "status": "awarded",
  "awarded_cents": 2500000
}

Responses

  • 200 The updated grant.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a grant: example response
{
  "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

Delete a grant parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Get a grant's position parameters
NameTypeRequiredDescription
idstring (uuid)Yes
as_ofstring (date)No

Report date. Defaults to today in the organization's timezone.

Responses

  • 200 The grant's position.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a grant's position: example response
{
  "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

Restore a deleted grant parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored grant, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted grant: example response
{
  "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

List receipts parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

contact_idstring (uuid)No
gift_idstring (uuid)No
kindone of: gift, year_endNo
tax_yearintegerNo

Responses

  • 200 A page of receipts.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List receipts: example response
{
  "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

Issue a receipt for a gift parameters
NameTypeRequiredDescription
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (ReceiptIssue)

Issue a receipt for a gift request body
NameTypeRequiredDescription
gift_idstring (uuid)Yes
send_viaone of: email, nullNo

email queues the receipt to be emailed to the donor (ADR-014) when email is set up on this deployment; null leaves it unsent. A donor who cannot be emailed gets a suppressed message with the reason instead.

reissuebooleanNo

Omit (false) to issue. True supersedes the gift's live receipt with a new rendering (both are kept). Requires reason; 409 when there is nothing to supersede.

reasonstringNo

Why the receipt is reissued. Printed on the new receipt and audited.

Example request
Issue a receipt for a gift: example request
{
  "gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13",
  "send_via": "email"
}

Responses

  • 201 The issued receipt.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Issue a receipt for a gift: example response
{
  "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)

Preview a gift receipt request body
NameTypeRequiredDescription
gift_idstring (uuid)Yes
reissuebooleanNo

Preview a reissue of the gift's live receipt (409 when it has none). Requires reason.

reasonstringNo

Printed on the reissued receipt, as in ReceiptIssue.

Example request
Preview a gift receipt: example request
{
  "gift_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d13"
}

Responses

  • 200 What issuing would print.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Preview a gift receipt: example response
{
  "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

Issue year-end statements in a batch parameters
NameTypeRequiredDescription
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (YearEndBatch)

Issue year-end statements in a batch request body
NameTypeRequiredDescription
tax_yearintegerYes
contact_idslist 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_viaone of: email, nullNo

email queues each statement to be emailed to its donor (ADR-014) when email is set up on this deployment; a donor who cannot be emailed gets a suppressed message with the reason instead.

reissuebooleanNo

Omit (false) to issue. True supersedes these donors' live statements for the year with new renderings. Requires contact_ids and reason.

reasonstringNo

Why the statements are reissued. Printed and audited.

Example request
Issue year-end statements in a batch: example request
{
  "tax_year": 2026
}

Responses

  • 200 Statements issued and donors skipped.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Issue year-end statements in a batch: example response
{
  "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)

Preview a year-end batch request body
NameTypeRequiredDescription
tax_yearintegerYes
contact_idslist 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
Preview a year-end batch: example request
{
  "tax_year": 2026
}

Responses

  • 200 What the batch would do.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Preview a year-end batch: example response
{
  "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

Get a receipt parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The receipt.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a receipt: example response
{
  "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

Get a download link for the receipt PDF parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 A signed download link.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a download link for the receipt PDF: example response
{
  "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

Download the receipt PDF with a signed link parameters
NameTypeRequiredDescription
idstring (uuid)Yes
tokenstringYes

Responses

  • 200 The receipt PDF.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Download the receipt PDF with a signed link: example response
"%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

Email a receipt to its donor parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 202 The queued email; it is sent within about a minute.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Email 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)
Email a receipt to its donor: example response
{
  "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

List communications parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

contact_idstring (uuid)No
channelone of: email, letter, call, text, meeting, noteNo
directionone of: inbound, outbound, internalNo
occurred_fromstring (date-time)No
occurred_tostring (date-time)No

Responses

  • 200 A page of communications.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List communications: example response
{
  "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)

Log a communication request body
NameTypeRequiredDescription
contact_idstring (uuid)Yes
channelone of: email, letter, call, text, meeting, noteYes
directionone of: inbound, outbound, internalYes
subjectstring or nullNo
body_textstring or nullNo
body_htmlstring or nullNo
message_idstring or nullNo

Email Message-ID, unique per organization; used for threading and dedupe.

thread_idstring or nullNo
from_addressstring or nullNo
to_addresseslist of string or nullNo
occurred_atstring (date-time)No
sourcestring or nullNo

postmark_inbound, mailbox_sync, manual or system.

Example request
Log a communication: 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

  • 201 The logged communication.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Log a communication: example response
{
  "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

Get a communication parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The communication.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a communication: example response
{
  "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

Update a communication parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (CommunicationUpdate)

Update a communication request body
NameTypeRequiredDescription
subjectstring or nullNo
body_textstring or nullNo
body_htmlstring or nullNo
occurred_atstring (date-time)No
channelone of: email, letter, call, text, meeting, noteNo
directionone of: inbound, outbound, internalNo
Example request
Update a communication: example request
{
  "subject": "Thank you for keeping the shelter warm"
}

Responses

  • 200 The updated communication.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a communication: example response
{
  "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

Delete a communication parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted communication parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored communication, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted communication: example response
{
  "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

List tasks parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

statusone of: open, done, dismissedNo
assigned_tostring (uuid)No
contact_idstring (uuid)No
grant_idstring (uuid)No
kindstringNo
due_beforestring (date)No

Inclusive.

due_afterstring (date)No

Inclusive.

Responses

  • 200 A page of tasks.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List tasks: example response
{
  "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)

Create a task request body
NameTypeRequiredDescription
contact_idstring (uuid) or nullNo
grant_idstring (uuid) or nullNo
kindstringNo
titlestringYes
due_onstring (date) or nullNo
assigned_tostring (uuid) or nullNo
Example request
Create a task: 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

  • 201 The new task.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a task: example response
{
  "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

Get a task parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The task.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a task: example response
{
  "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

Update a task parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (TaskUpdate)

Update a task request body
NameTypeRequiredDescription
titlestringNo
due_onstring (date) or nullNo
statusone of: open, done, dismissedNo
assigned_tostring (uuid) or nullNo
Example request
Update a task: example request
{
  "status": "done"
}

Responses

  • 200 The updated task.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a task: example response
{
  "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

Delete a task parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted task parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored task, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted task: example response
{
  "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

Snooze a task parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (TaskSnooze)

Snooze a task request body
NameTypeRequiredDescription
untilstring (date) or nullYes
Example request
Snooze a task: example request
{
  "until": "2026-10-15"
}

Responses

  • 200 The task with `snoozed_until` set or cleared.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Snooze a task: example response
{
  "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_on is 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

Get the daily action queue parameters
NameTypeRequiredDescription
datestring (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_sectionintegerNo

Most items returned per section; counts always has the full numbers.

Responses

  • 200 The queue.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get the daily action queue: example response
{
  "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)

Put a lapsed donor in the action queue request body
NameTypeRequiredDescription
contact_idstring (uuid)Yes
Example request
Put a lapsed donor in the action queue: example request
{
  "contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d06"
}

Responses

  • 200 The task already existed; open now.
  • 201 The task was created.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Put a lapsed donor in the action queue: example response
{
  "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

List import jobs parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

statusone of: uploaded, previewed, committed, rolled_back, failed, discardedNo

Responses

  • 200 A page of import jobs.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List import jobs: example response
{
  "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)

Upload a file to import request body
NameTypeRequiredDescription
filestring (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, and a zip or a workbook only through file_base64 or files (in multipart, a file part is sent as bytes).

file_base64stringNo

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 file, measured on the decoded bytes.

fileslist of objectNo

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.

sheetstringNo

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 /sheet that lists its worksheets. Ignored for CSV and zip files.

source_namestringNo

Defaults to the uploaded filename.

Example request
Upload a file to import: example request
{
  "file": "donors-2025.csv (binary)",
  "source_name": "donors-2025.csv"
}

Responses

  • 201 The job, detected columns and a suggested mapping.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Upload a file to import: example response
{
  "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

Get an import job parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The import job and what its upload found.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get an import job: example response
{
  "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

Preview an import with a column mapping parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (ImportPreviewRequest)

Preview an import with a column mapping request body
NameTypeRequiredDescription
column_mappingColumnMappingYes

Source column header → target field. Targets: contact.<field> (any scalar Contact field), contact.full_name (one name cell, parsed: "Smith, John", "Mr. John A. Smith Jr.", a couple on one row such as "John & Jane Smith" becomes two contacts in one household, organization names), contact.is_organization (yes/no), contact.custom_fields.<key> (lowercase letters, digits and underscores; kept as text), household.name, gift.amount (dollars or cents, auto-detected per column), gift.amount_cents, gift.received_on, gift.fund (fund name or code), gift.campaign, gift.appeal, gift.kind, gift.payment_method, gift.external_ref (on a check, the check number: 1 to 10 digits, a leading "#" aside; anything else, and on any gift a reference that held a check line, a card number or a bank account number, is left off the gift with an issue), gift.notes, gift.fair_market_value (dollars the donor received in return), gift.fee_covered (dollars of a processing fee the donor chose to cover, inside the amount: E7.1), gift.in_kind_description, gift.in_kind_value (dollars; the amount of an in-kind gift whose amount cell is blank), contact.vendor_id and gift.vendor_id (the exporting system's own donor and gift ids, kept on the import row so importing the same export again skips gifts already imported and attaches to donors already created), gift.soft_credit_names and gift.soft_credit_amount (other people credited with the gift, returned as suggestions; they never create money), gift.tribute_type (in_honor_of or in_memory_of), gift.tribute_name, gift.tribute_first_name (joined before tribute_name), gift.pledge_payment (yes/no; yes makes a one-time gift a pledge payment), or skip. A column whose name says it holds bank or card details, or a wealth-screening score (ImportWithheldColumns), takes only skip. Whatever the mapping, a card or bank number is never imported: the upload took it out of every cell, and the preview reports the removal on the row as an issue on the field, without the number. A contact.phone, contact.email or contact.postal_code cell, or an id, that had a number taken out is left empty (round 4), never the marker.

Example request
Preview an import with a column mapping: 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

  • 200 What the commit will do.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Preview an import with a column mapping: example response
{
  "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

Accept all suggested matches parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 How many suggested matches were accepted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Accept all suggested matches: example response
{
  "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

Undo accept-all parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 How many rows were put back.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Undo accept-all: example response
{
  "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

Commit a previewed import parameters
NameTypeRequiredDescription
idstring (uuid)Yes
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (ImportCommitRequest)

Commit a previewed import request body
NameTypeRequiredDescription
resolutionslist of ImportRowResolutionNo

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
Commit a previewed import: 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

  • 200 The committed job.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Commit a previewed import: example response
{
  "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

Roll back a committed import parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The rolled-back job.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Roll back a committed import: example response
{
  "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

List a previewed import's rows parameters
NameTypeRequiredDescription
idstring (uuid)Yes
filterone of: problems, duplicates, already_imported, allNo

Which rows: all (the default), problems, duplicates (the rows to review) or already_imported.

limitintegerNo

Page size, 1–200; 200 by default, the preview's own page (most lists default to 50).

cursorstringNo

Opaque cursor from a previous response's next_cursor.

Responses

  • 200 A page of rows.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List a previewed import's rows: example response
{
  "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

Discard an import that was never imported parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The discarded import.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Discard an import that was never imported: example response
{
  "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

List API keys parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_revokedbooleanNo

Responses

  • 200 A page of API keys.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List API keys: example response
{
  "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)

Create an API key request body
NameTypeRequiredDescription
namestringYes
scopeslist of one of: read, writeNo
Example request
Create an API key: example request
{
  "name": "Zapier integration",
  "scopes": [
    "read",
    "write"
  ]
}

Responses

  • 201 The key, including its secret.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create an API key: example response
{
  "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

Get an API key parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The key.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get an API key: example response
{
  "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

Revoke an API key parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The revoked key.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Revoke an API key: example response
{
  "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

List webhook subscriptions parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

activebooleanNo

Responses

  • 200 A page of subscriptions.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List webhook subscriptions: example response
{
  "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)

Create a webhook subscription request body
NameTypeRequiredDescription
urlstring (uri)Yes
eventslist 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.testYes
activebooleanNo
Example request
Create a webhook subscription: example request
{
  "url": "https://hooks.example.com/operation-charity",
  "events": [
    "gift.created",
    "receipt.issued",
    "contact.updated"
  ]
}

Responses

  • 201 The subscription with its secret.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a webhook subscription: example response
{
  "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

Get a webhook subscription parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The subscription.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a webhook subscription: example response
{
  "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

Update a webhook subscription parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (WebhookSubscriptionUpdate)

Update a webhook subscription request body
NameTypeRequiredDescription
urlstring (uri)No
eventslist 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.testNo
activebooleanNo
Example request
Update a webhook subscription: example request
{
  "active": false
}

Responses

  • 200 The updated subscription.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a webhook subscription: example response
{
  "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

Delete a webhook subscription parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted webhook subscription parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored webhook subscription, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted webhook subscription: example response
{
  "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

Send a test delivery parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 202 The test event was queued.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (202)
Send a test delivery: example response
{
  "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

Rotate the signing secret parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The subscription with its new secret.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Rotate the signing secret: example response
{
  "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

List a subscription's deliveries parameters
NameTypeRequiredDescription
idstring (uuid)Yes
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

statusone of: pending, delivered, failedNo
event_typeone 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.testNo

Responses

  • 200 A page of deliveries.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List a subscription's deliveries: example response
{
  "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

Retry a delivery parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The delivery, pending again.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Retry a delivery: example response
{
  "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

Donor retention rate parameters
NameTypeRequiredDescription
fiscal_yearintegerYes

Fiscal year, named for the calendar year in which it ends (uses the organization's fiscal_year_start_month).

basisone of: fiscal, calendarNo

fiscal (default) uses the organization's fiscal year; calendar uses January–December, comparable with the Fundraising Effectiveness Project's published benchmarks. With calendar, fiscal_year names the calendar year. The response states the basis used (ADR-008).

yearsintegerNo

How many years the retention table covers, ending with fiscal_year (C5).

Responses

  • 200 The retention figures.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Donor retention rate: example response
{
  "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

LYBUNT donors parameters
NameTypeRequiredDescription
fiscal_yearintegerYes

Fiscal year, named for the calendar year in which it ends (uses the organization's fiscal_year_start_month).

basisone of: fiscal, calendarNo

fiscal (default) uses the organization's fiscal year; calendar uses January–December, comparable with the Fundraising Effectiveness Project's published benchmarks. With calendar, fiscal_year names the calendar year. The response states the basis used (ADR-008).

limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

Responses

  • 200 A page of lapsed donors.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
LYBUNT donors: example response
{
  "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

SYBUNT donors parameters
NameTypeRequiredDescription
fiscal_yearintegerYes

Fiscal year, named for the calendar year in which it ends (uses the organization's fiscal_year_start_month).

basisone of: fiscal, calendarNo

fiscal (default) uses the organization's fiscal year; calendar uses January–December, comparable with the Fundraising Effectiveness Project's published benchmarks. With calendar, fiscal_year names the calendar year. The response states the basis used (ADR-008).

limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

Responses

  • 200 A page of long-lapsed donors.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
SYBUNT donors: example response
{
  "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

Restricted balances per fund parameters
NameTypeRequiredDescription
as_ofstring (date)No

Report date. Defaults to today in the organization's timezone.

Responses

  • 200 One row per fund.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restricted balances per fund: example response
{
  "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

Giving by fund parameters
NameTypeRequiredDescription
date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

Responses

  • 200 One row per fund with gifts in the range.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Giving by fund: example response
{
  "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

Grant status parameters
NameTypeRequiredDescription
as_ofstring (date)No

Report date. Defaults to today in the organization's timezone.

statusone of: prospect, applied, awarded, declined, closedNo

Responses

  • 200 One row per grant.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Grant status: example response
{
  "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

Export the retention table as CSV parameters
NameTypeRequiredDescription
formatone of: csvYes

Export file format (C5). csv is RFC 4180 UTF-8 with a header row, CRLF line endings and amounts as plain decimal dollars (1234.50).

fiscal_yearintegerYes

Fiscal year, named for the calendar year in which it ends (uses the organization's fiscal_year_start_month).

basisone of: fiscal, calendarNo

fiscal (default) uses the organization's fiscal year; calendar uses January–December, comparable with the Fundraising Effectiveness Project's published benchmarks. With calendar, fiscal_year names the calendar year. The response states the basis used (ADR-008).

yearsintegerNo

How many years the retention table covers, ending with fiscal_year (C5).

Responses

  • 200 The retention table.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Export the retention table as CSV: example response
"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

Export LYBUNT donors as CSV parameters
NameTypeRequiredDescription
formatone of: csvYes

Export file format (C5). csv is RFC 4180 UTF-8 with a header row, CRLF line endings and amounts as plain decimal dollars (1234.50).

fiscal_yearintegerYes

Fiscal year, named for the calendar year in which it ends (uses the organization's fiscal_year_start_month).

basisone of: fiscal, calendarNo

fiscal (default) uses the organization's fiscal year; calendar uses January–December, comparable with the Fundraising Effectiveness Project's published benchmarks. With calendar, fiscal_year names the calendar year. The response states the basis used (ADR-008).

Responses

  • 200 The donors.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Export LYBUNT donors as CSV: example response
"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

Export SYBUNT donors as CSV parameters
NameTypeRequiredDescription
formatone of: csvYes

Export file format (C5). csv is RFC 4180 UTF-8 with a header row, CRLF line endings and amounts as plain decimal dollars (1234.50).

fiscal_yearintegerYes

Fiscal year, named for the calendar year in which it ends (uses the organization's fiscal_year_start_month).

basisone of: fiscal, calendarNo

fiscal (default) uses the organization's fiscal year; calendar uses January–December, comparable with the Fundraising Effectiveness Project's published benchmarks. With calendar, fiscal_year names the calendar year. The response states the basis used (ADR-008).

Responses

  • 200 The donors.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Export SYBUNT donors as CSV: example response
"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

Export giving by fund as CSV parameters
NameTypeRequiredDescription
formatone of: csvYes

Export file format (C5). csv is RFC 4180 UTF-8 with a header row, CRLF line endings and amounts as plain decimal dollars (1234.50).

date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

Responses

  • 200 One row per fund and a total.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Export giving by fund as CSV: example response
"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

Export restricted balances as CSV parameters
NameTypeRequiredDescription
formatone of: csvYes

Export file format (C5). csv is RFC 4180 UTF-8 with a header row, CRLF line endings and amounts as plain decimal dollars (1234.50).

as_ofstring (date)No

Report date. Defaults to today in the organization's timezone.

Responses

  • 200 One row per fund.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Export restricted balances as CSV: example response
"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

Export grant status as CSV parameters
NameTypeRequiredDescription
formatone of: csvYes

Export file format (C5). csv is RFC 4180 UTF-8 with a header row, CRLF line endings and amounts as plain decimal dollars (1234.50).

as_ofstring (date)No

Report date. Defaults to today in the organization's timezone.

statusone of: prospect, applied, awarded, declined, closedNo

Responses

  • 200 One row per grant.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Export grant status as CSV: example response
"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

List audit log entries parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

table_namestringNo

gifts, gift_adjustments or any audited table.

row_idstring (uuid)No
actorstring (uuid)No

User id.

date_fromstring (date)No

Inclusive lower bound on the record's business date.

date_tostring (date)No

Inclusive upper bound on the record's business date.

Responses

  • 200 A page of audit entries.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List audit log entries: example response
{
  "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

Get a tax year's IRS figures parameters
NameTypeRequiredDescription
yearintegerYes

Tax year.

Responses

  • 200 The figures.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a tax year's IRS figures: example response
{
  "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

  • 200 The payment account.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Online 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)
Get the organization's Stripe account: example response
{
  "status": "active",
  "stripe_account_id": "acct_1QAbCdEfGhIjKlMn",
  "charges_enabled": true,
  "payouts_enabled": true,
  "details_submitted": true,
  "requirements_due": []
}

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

List donation forms parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

activebooleanNo

Responses

  • 200 A page of donation forms.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List donation forms: example response
{
  "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)

Create a donation form request body
NameTypeRequiredDescription
slugstringYes
titlestringYes
introstring or nullNo
logo_urlstring or nullNo

Must start with https://.

accent_colorstring or nullNo

#rrggbb; stored in lowercase.

preset_amounts_centslist of integer (int64)Yes

Integer cents. 1–6 amounts, each between the minimum and the maximum.

default_amount_centsinteger (int64) or nullNo

Integer cents. Between the minimum and the maximum.

allow_custom_amountbooleanNo
min_amount_centsinteger (int64)No

Integer cents

max_amount_centsinteger (int64)No

Integer cents. Above min_amount_cents.

frequencieslist of one of: one_time, month, yearYes
fund_idslist of string (uuid)Yes
campaign_idstring (uuid) or nullNo
appeal_idstring (uuid) or nullNo
thank_you_messagestring or nullNo
allow_tributesbooleanNo
activebooleanNo
Example request
Create a donation form: 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

  • 201 The new donation form.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create a donation form: example response
{
  "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

Get a donation form parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The donation form.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a donation form: example response
{
  "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

Update a donation form parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (DonationFormUpdate)

Update a donation form request body
NameTypeRequiredDescription
slugstringNo

Changing the slug changes the public address; links to the old one stop working.

titlestringNo
introstring or nullNo
logo_urlstring or nullNo

Must start with https://.

accent_colorstring or nullNo

#rrggbb; stored in lowercase.

preset_amounts_centslist of integer (int64)No

Integer cents

default_amount_centsinteger (int64) or nullNo

Integer cents

allow_custom_amountbooleanNo
min_amount_centsinteger (int64)No

Integer cents

max_amount_centsinteger (int64)No

Integer cents

frequencieslist of one of: one_time, month, yearNo
fund_idslist of string (uuid)No
campaign_idstring (uuid) or nullNo
appeal_idstring (uuid) or nullNo
thank_you_messagestring or nullNo
allow_tributesbooleanNo
activebooleanNo
Example request
Update a donation form: example request
{
  "active": false
}

Responses

  • 200 The updated donation form.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update a donation form: example response
{
  "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

Delete a donation form parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted donation form parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored donation form, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted donation form: example response
{
  "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

Get a donation form's public page parameters
NameTypeRequiredDescription
slugstringYes

The form's public address, as in /give/<slug>.

Responses

  • 200 The public form.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a donation form's public page: example response
{
  "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

Start paying on Stripe Checkout parameters
NameTypeRequiredDescription
slugstringYes

The form's public address, as in /give/<slug>.

Request body (CheckoutSessionCreate)

Start paying on Stripe Checkout request body
NameTypeRequiredDescription
amount_centsinteger (int64)Yes

Integer cents. Charged each time for a monthly or yearly gift.

frequencyone of: one_time, month, yearYes

one_time is a single charge; month and year start a recurring donation (a Stripe subscription).

fund_idstring (uuid)Yes
first_namestringYes
last_namestringYes
emailstring (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_inbooleanYes

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_refstringNo

The ref code of an email send's donation link (ADR-023): a started send whose link names this form gives the checkout its campaign and appeal. Any other code, a malformed one included, is ignored, so a mangled link still lets the donor give.

tribute_typeone of: in_honor_of, in_memory_of or any or nullNo

Give in honor or in memory of someone named in tribute_name. Omit or null for neither.

tribute_namestring or nullNo
Example request
Start paying on Stripe Checkout: 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

  • 201 A Stripe Checkout session.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Online 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)
Start paying on Stripe Checkout: example response
{
  "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

List recurring donations parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

contact_idstring (uuid)No
statusone of: active, past_due, canceledNo

Responses

  • 200 A page of recurring donations.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List recurring donations: example response
{
  "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

Get a recurring donation parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The recurring donation.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a recurring donation: example response
{
  "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

Cancel a recurring donation at the end of its period parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The recurring donation.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Online 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)
Cancel a recurring donation at the end of its period: example response
{
  "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

Undo a cancel before the period ends parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The recurring donation.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Online 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)
Undo a cancel before the period ends: example response
{
  "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"
}

Email

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

  • 200 The email settings.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get the email settings: example response
{
  "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)

Change the sender settings request body
NameTypeRequiredDescription
email_from_namestring or nullNo

1–100 characters, without quotes, angle brackets or control characters (they could break the From header).

email_from_local_partstringNo

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_tostring or nullNo

One email address.

Example request
Change the sender settings: example request
{
  "email_from_name": "Maple Street Food Bank",
  "email_from_local_part": "donations",
  "email_reply_to": "office@maplestreetfoodbank.org"
}

Responses

  • 200 The email settings.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Change the sender settings: example response
{
  "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)

Add the organization's own sending domain request body
NameTypeRequiredDescription
domainstringYes

The domain mail should come from, e.g. maplestreetfoodbank.org: no address, no scheme. Case and a trailing dot are ignored.

Example request
Add the organization's own sending domain: example request
{
  "domain": "maplestreetfoodbank.org"
}

Responses

  • 200 The organization's removed claim on this domain, restored as it was.
  • 201 The domain, pending until all three DNS records are verified.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Email 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)
Add the organization's own sending domain: example response
{
  "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

  • 204 Removed.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

  • 200 The domain with what was found.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Email 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)
Check the sending domain's DNS records: example response
{
  "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

  • 200 The restored domain, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore the removed sending domain: example response
{
  "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

  • 202 The queued test email; it is sent within about a minute.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Email 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)
Send a test email to yourself: example response
{
  "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

List emails parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

contact_idstring (uuid)No
receipt_idstring (uuid)No
statusone of: queued, sent, failed, suppressed, bouncedNo
kindone of: receipt, welcome_thank_you, welcome_impact, welcome_ask, test, appeal, appeal_test, send_notice, signup_confirmationNo
email_send_idstring (uuid)No

An email send's messages: each recipient's status and reason, with delivered_at, unsubscribed_at and complained_at (ADR-023).

Responses

  • 200 A page of emails.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List emails: example response
{
  "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

Get an email parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The email.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get an email: example response
{
  "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

Retry a failed email parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The email, queued again.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Email 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)
Retry a failed email: example response
{
  "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

List suppressed addresses parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

emailstringNo

One address, compared without regard to case.

reasonone of: hard_bounce, spam_complaint, unsubscribe, manualNo

Responses

  • 200 A page of suppressions.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List suppressed addresses: example response
{
  "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)

Suppress an address by hand request body
NameTypeRequiredDescription
emailstringYes

One email address; stored lowercased.

reasonone of: manualYes
Example request
Suppress an address by hand: example request
{
  "email": "pat.rivera@example.org",
  "reason": "manual"
}

Responses

  • 201 The suppression.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Suppress an address by hand: example response
{
  "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

Get a suppression parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The suppression.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a suppression: example response
{
  "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

Reactivate a suppressed address parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Reactivated.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Suppress a reactivated address again parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The suppression, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Suppress a reactivated address again: example response
{
  "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

  • 200 Reopened.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Reopen the sign-up page: example response
{
  "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

  • 200 The welcome series.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get the welcome series: example response
{
  "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)

Change the welcome series request body
NameTypeRequiredDescription
enabledbooleanNo
marketing_requires_opt_inbooleanNo
stepsobjectNo
Example request
Change the welcome series: example request
{
  "enabled": true
}

Responses

  • 200 The welcome series.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Change the welcome series: example response
{
  "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)

Preview a welcome email request body
NameTypeRequiredDescription
stepone of: thank_you, impact_story, second_askYes

The series' steps in the order they are sent: thank_you (transactional stream), then impact_story and second_ask (broadcast stream).

contact_idstring (uuid) or nullNo

Render with this contact's own values: their first gift, its fund and their suggested ask. Omitted or null: sample values.

subjectstringNo

An unsaved subject to render instead of the saved one.

bodystringNo

An unsaved body to render instead of the saved one.

Example request
Preview a welcome email: example request
{
  "step": "thank_you",
  "contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}

Responses

  • 200 The rendered email.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Preview a welcome email: example response
{
  "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&#39;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

List welcome enrolments parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

contact_idstring (uuid)No

Only this contact's enrolment.

statusone of: active, completed, stoppedNo

Responses

  • 200 A page of enrolments.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List welcome enrolments: example response
{
  "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

Get a welcome enrolment parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The enrolment.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get a welcome enrolment: example response
{
  "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

Stop a donor's welcome series parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (WelcomeEnrollmentStop)

Stop a donor's welcome series request body
NameTypeRequiredDescription
reasonstringNo

Why, in plain words. Omitted: "Stopped by <your name>".

Example request
Stop a donor's welcome series: example request
{
  "reason": "Asked us by phone not to email for now."
}

Responses

  • 200 The stopped enrolment.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Stop a donor's welcome series: example response
{
  "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

Resume a stopped welcome series parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The active enrolment.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Resume a stopped welcome series: example response
{
  "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

Check an unsubscribe link parameters
NameTypeRequiredDescription
tokenstringYes

The token from the unsubscribe link.

Responses

  • 200 The organization's name and whether the donor is unsubscribed already.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Unsubscribe 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)
Check an unsubscribe link: example response
{
  "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)

Unsubscribe from an organization's email updates request body
NameTypeRequiredDescription
tokenstringYes

The token from the unsubscribe link.

Example request
Unsubscribe from an organization's email updates: example request
{
  "token": "eyJjIjoiM2Y5ZDJjMWEtN2I0ZS00ZjZhLTljMmQtMWU1YjhhN2M2ZDA1IiwibyI6IjNmOWQyYzFhLTdiNGUtNGY2YS05YzJkLTFlNWI4YTdjNmQwMSJ9.nM0sHqjvBQ2k7l3o0h4Yb0x5mI6lQ9w8Z7r2c1v8XyA"
}

Responses

  • 200 Unsubscribed.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Unsubscribe 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)
Unsubscribe from an organization's email updates: example response
{
  "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

One-click unsubscribe from a mail client parameters
NameTypeRequiredDescription
tokenstringYes

The token from the unsubscribe link.

Request body (object)

One-click unsubscribe from a mail client request body
NameTypeRequiredDescription
List-Unsubscribeone of: One-ClickNo
Example request
One-click unsubscribe from a mail client: example request
{
  "List-Unsubscribe": "One-Click"
}

Responses

  • 200 Unsubscribed.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Unsubscribe 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)
One-click unsubscribe from a mail client: example response
{
  "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

What the preferences page shows parameters
NameTypeRequiredDescription
tokenstringYes

Responses

  • 200 The person's choices.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Unsubscribe 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)
What the preferences page shows: example response
{
  "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)

Change email preferences request body
NameTypeRequiredDescription
tokenstringYes
changeone of: unsubscribe, stop_appeals, stop_newslettersYes
Example request
Change email preferences: example request
{
  "token": "eyJjIjoiM2Y5ZCJ9.sig",
  "change": "stop_appeals"
}

Responses

  • 200 Changed (or already so).
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Unsubscribe 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)
Change email preferences: example response
{
  "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)

Undo a change on the preferences page request body
NameTypeRequiredDescription
tokenstringYes
undo_idstringYes
Example request
Undo a change on the preferences page: example request
{
  "token": "eyJjIjoiM2Y5ZCJ9.sig",
  "undo_id": "Zm9vYmFyYmF6cXV4cXV1eHF1dXgxMjM0NTY3OA"
}

Responses

  • 200 The choices after the undo.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Unsubscribe 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)
Undo a change on the preferences page: example response
{
  "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

What the sign-up page shows parameters
NameTypeRequiredDescription
codestringYes

Responses

  • 200 The page.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
What the sign-up page shows: example response
{
  "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)

Ask for email updates request body
NameTypeRequiredDescription
codestringYes

The page's code (/subscribe/<code>).

first_namestringYes
last_namestring or nullNo
emailstringYes
websitestringNo

Leave empty (a field people never see).

challengestringYes
noncestringYes
Example request
Ask for email updates: example request
{
  "code": "k7d2m4qx",
  "first_name": "Dana",
  "email": "dana@example.org",
  "website": "",
  "challenge": "eyJvIjoiM2Y5ZCJ9.c2ln",
  "nonce": "1a2b"
}

Responses

  • 202 Received.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (202)
Ask for email updates: example response
{
  "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

What the confirmation page shows parameters
NameTypeRequiredDescription
tokenstringYes

Responses

  • 200 Whether the link can be used.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Sign-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)
What the confirmation page shows: example response
{
  "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)

Confirm email updates request body
NameTypeRequiredDescription
tokenstringYes
Example request
Confirm email updates: example request
{
  "token": "eyJzIjoiM2Y5ZCJ9.c2ln"
}

Responses

  • 200 The outcome.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Sign-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)
Confirm email updates: example response
{
  "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

List email templates parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

qstringNo

Part of the name.

Responses

  • 200 A page of templates.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List email templates: example response
{
  "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)

Create an email template request body
NameTypeRequiredDescription
namestringYes
contentEmailContentYes

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
Create an email template: 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

  • 201 Created.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Create an email template: example response
{
  "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

Get an email template parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The template, deleted ones too.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get an email template: example response
{
  "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

Update an email template parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailTemplateUpdate)

Update an email template request body
NameTypeRequiredDescription
namestringNo
contentEmailContentNo

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
Update an email template: example request
{
  "name": "Spring newsletter (2026)"
}

Responses

  • 200 Updated.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Update an email template: example response
{
  "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

Delete an email template parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted email template parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 Restored.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted email template: example response
{
  "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

Preview an email template parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailPreviewRequest)

Preview an email template request body
NameTypeRequiredDescription
contact_idstring (uuid) or nullNo

Draw it as this contact would get it; sample values when absent.

contentEmailContentNo

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
Preview an email template: example request
{
  "contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}

Responses

  • 200 The email as drawn.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Preview an email template: example response
{
  "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

List email sends parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

statusone of: draft, scheduled, sending, paused, sent, canceledNo

Responses

  • 200 A page of sends.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List email sends: example response
{
  "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)

Draft an email send request body
NameTypeRequiredDescription
namestringYes
topicone of: appeal, newsletterYes
template_idstring (uuid)No
contentEmailContentNo

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_idstring (uuid) or nullNo
appeal_idstring (uuid) or nullNo
household_optionone of: per_person, per_householdNo
track_clicksbooleanNo
Example request
Draft an email send: 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

  • 201 Created as a draft.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (201)
Draft an email send: example response
{
  "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

Get an email send parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The send, deleted ones too.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get an email send: example response
{
  "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

Change a draft parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailSendUpdate)

Change a draft request body
NameTypeRequiredDescription
namestringNo
topicone of: appeal, newsletterNo
contentEmailContentNo

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_idstring (uuid) or nullNo
appeal_idstring (uuid) or nullNo
household_optionone of: per_person, per_householdNo
track_clicksbooleanNo
Example request
Change a draft: example request
{
  "track_clicks": true
}

Responses

  • 200 Updated.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Change a draft: example response
{
  "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

Delete a draft parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Deleted.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore a deleted draft parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 Restored.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore a deleted draft: example response
{
  "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

Preview a send parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailPreviewRequest)

Preview a send request body
NameTypeRequiredDescription
contact_idstring (uuid) or nullNo

Draw it as this contact would get it; sample values when absent.

contentEmailContentNo

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
Preview a send: example request
{
  "contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
}

Responses

  • 200 The email as drawn.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Preview a send: example response
{
  "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

Send a test parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailSendTestRequest)

Send a test request body
NameTypeRequiredDescription
contact_idstring (uuid) or nullNo

Draw it as this contact would get it; sample values when absent.

user_idslist of string (uuid)No

Up to five other active users; the caller always gets it.

Example request
Send a test: example request
{
  "contact_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05",
  "user_ids": [
    "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d03"
  ]
}

Responses

  • 202 Queued; the drain sends it within about a minute.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (202)
Send a test: example response
{
  "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

Review a draft parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 Reviewed.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Working 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)
Review a draft: example response
{
  "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

Approve a reviewed send parameters
NameTypeRequiredDescription
idstring (uuid)Yes
Idempotency-Key (header)stringYes

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 Idempotent-Replayed: true; the same key with a different body returns 409.

Request body (EmailSendApproval)

Approve a reviewed send request body
NameTypeRequiredDescription
snapshot_idstring (uuid)Yes
content_hashstringYes
members_hashstringYes
start_atstring (date-time) or nullNo

A later start; never earlier than 10 minutes after approval.

Example request
Approve a reviewed send: example request
{
  "snapshot_id": "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c7d01",
  "content_hash": "5b2f0a9c4d8e1f3a6b7c9d0e2f4a6b8c0d1e3f5a7b9c2d4e6f8a0b1c3d5e7f90",
  "members_hash": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
}

Responses

  • 200 Approved and scheduled.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 The 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.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
  • 503 Email 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)
Approve a reviewed send: example response
{
  "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

Unschedule a send before it starts parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 A draft again.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Unschedule a send before it starts: example response
{
  "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

Pause a send that is going out parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 Paused.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Pause a send that is going out: example response
{
  "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

Resume a paused or canceled send parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 Going out again.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Resume a paused or canceled send: example response
{
  "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

Cancel a send parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (EmailSendCancel)

Cancel a send request body
NameTypeRequiredDescription
confirmone of: trueYes

Canceling stops every email of this send that hasn't gone yet; resume undoes it.

Example request
Cancel a send: example request
{
  "confirm": true
}

Responses

  • 200 Canceled.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Cancel a send: example response
{
  "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

A send's results parameters
NameTypeRequiredDescription
idstring (uuid)Yes
daysintegerNo

Responses

  • 200 The results.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
A send's results: example response
{
  "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

  • 200 The email settings with the new `bcc_address`.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Replace the BCC capture address: example response
{
  "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

List captured email parameters
NameTypeRequiredDescription
limitintegerNo

Page size, 1–200.

cursorstringNo

Opaque cursor from a previous response's next_cursor.

include_deletedbooleanNo

Admin only. When true, soft-deleted records (deleted_at set) are included in the page. Anyone else gets 403 when they pass it.

statuslist of one of: filed, unmatched, needs_reviewNo

Only these statuses: one, or a comma-separated list such as unmatched,needs_review (the inbox), sorted newest first across them. Omit for all. 400 for an unknown or repeated status.

Responses

  • 200 A page of captured email.
  • 400 Malformed request (bad cursor, unknown filter, unparseable body).
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
List captured email: example response
{
  "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

Get captured email parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The captured email.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Get captured email: example response
{
  "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

Discard captured email parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 204 Discarded.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 429 Rate limit exceeded.
  • 500 Something 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

Restore discarded email parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Responses

  • 200 The restored email, with `deleted_at` null.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
Restore discarded email: example response
{
  "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

File captured email to contacts parameters
NameTypeRequiredDescription
idstring (uuid)Yes

Request body (InboundEmailFile)

File captured email to contacts request body
NameTypeRequiredDescription
contact_idslist of string (uuid)Yes

Live contacts to file the email to, each once.

Example request
File captured email to contacts: example request
{
  "contact_ids": [
    "3f9d2c1a-7b4e-4f6a-9c2d-1e5b8a7c6d05"
  ]
}

Responses

  • 200 The filed email, with the timeline entries it created.
  • 401 Missing, invalid, expired or revoked credential.
  • 403 The 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`.
  • 404 No such record in this organization.
  • 409 Uniqueness or state conflict, or an Idempotency-Key reused with a different body.
  • 422 The body parsed but failed validation.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. The request can be retried; nothing was written.
Example response (200)
File captured email to contacts: example response
{
  "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
}