Some units watch a mailbox: when the bank or Zelle emails that a family paid, a script records the payment in Campfire. This endpoint is for that kind of automation. It records exactly what Record Payment does in the app, with no preview step, and it is safe to retry.
You need a token with payments:write (Record payments in the app), the finances:manage permission, Campfire Pro or a trial, and finances turned on for the unit. The examples use the campfire.py helper from Getting started.
Record a payment
POST /organizations/{orgId}/payments
POST /api/v1/organizations/{orgId}/payments
Content-Type: application/json
{
"memberId": "k3P9...",
"amountCents": 4500,
"method": "zelle",
"paidOn": "2026-11-02",
"eventId": "ev7...",
"description": "Zelle from J. Smith",
"reference": "ZL-3F9A2C71"
}
| Field | Rules |
|---|---|
memberId (required) | Any member of the unit, as a Campfire id. The payment goes on their family's account, or on their own account when they have no family. |
amountCents (required) | Whole cents, 1 to 1,000,000. |
method (required) | cash, check, zelle, venmo, cash_app, paypal or ach. Card payments come only through Campfire's own online payments. |
paidOn (required) | Exactly YYYY-MM-DD. Up to a day ahead is allowed, for time zones. At most a year back. |
eventId | Ties the payment to an event, so the event's readiness list shows the family as paid. Leave it out, or send null, for no event. |
description | Up to 200 characters. Defaults to "Payment", or "Payment: " and the event's title. |
reference (required) | Up to 128 characters: the bank's or app's confirmation number. One payment per reference in the unit. |
The answer is 201 with the payment:
{
"data": {
"id": "api_6f0c2d9a1b7e4c3f8a2d1e0b",
"memberId": "k3P9...",
"accountType": "family",
"amountCents": 4500,
"method": "zelle",
"paidOn": "2026-11-02",
"eventId": "ev7...",
"description": "Zelle from J. Smith",
"reference": "ZL-3F9A2C71",
"createdAt": "2026-11-02T15:04:05.000Z"
},
"requestId": "..."
}
idstarts withapi_.accountTypeisfamilyorindividual.createdAtis when Campfire recorded it;paidOnis the day it was paid.- The family's balance goes down in the same step. The payment is saved as completed, marked as recorded through the API, with the token's owner as the person who recorded it.
- Partial payments are just smaller amounts. The event's readiness list already shows what is still owed.
- Nobody is notified.
Retries are safe
reference fixes the payment's id. That makes retrying safe, even after a timeout where you don't know whether the first try landed:
| You send | Campfire answers |
|---|---|
| A new reference | 201, recorded. |
| The same reference and the same fields again | 200 with the payment already recorded. Nothing is added. This answer comes before any other check, so it holds even if the month has since been closed, the member has left or the date is now over a year back. |
| The same reference with a different member, amount, method, date, event or description | 409 reference_conflict. A person should look. |
A script that reads the same emails twice can never record a payment twice, as long as it sends the confirmation number as reference.
Closed months
When the treasurer has closed the books for a month, a new payment dated in it is refused with 409 month_closed, and so is voiding one. Ask the treasurer to reopen the month in the app first. Campfire reads the end of a closed month as the treasurer's local date, so the 1st of the next month stays open. A retry of a payment that was recorded before the close still gets its 200.
Read and void
- GET /organizations/{orgId}/payments/{paymentId} returns the payment, as above.
- DELETE /organizations/{orgId}/payments/{paymentId} voids it: the payment is removed and the amount is credited back to the family the payment is on now (if the member has moved to another family since, that family). The answer is
{"voided": true}. After a void, the same reference can be recorded again. - Both work only on payments recorded through the API. Payments recorded in the app or paid online return
404; change those in the app. - Both need
payments:writeand the same permission and plan as recording.
curl -sS -X DELETE \
-H "Authorization: Bearer $CAMPFIRE_API_TOKEN" \
https://troopcampfire.app/api/v1/organizations/$ORG_ID/payments/api_6f0c2d9a1b7e4c3f8a2d1e0b
# {"data":{"voided":true},"requestId":"..."}
Errors to expect
| Response | Means |
|---|---|
400 invalid_argument | A field is missing or out of range, or no member or event in the unit has that id. The message says which. |
403 insufficient_scope | The token doesn't have payments:write. |
403 access_denied | The token's owner no longer has finances:manage. |
403 feature_disabled | The unit isn't on Campfire Pro, or finances are turned off. |
409 reference_conflict | That reference was already used for a different payment. |
409 month_closed | The date is in a closed month. |
429 rate_limited | More than 120 new payments in an hour from this token (retries of a recorded payment don't count). Wait Retry-After seconds. |
Example: Zelle emails
Matching a Zelle sender to a family is the script's job. Zelle emails usually carry the sender's name, so keep a small table from sender to a member of that family. A token with Contact details can help you build it from the roster's emails; see Read your roster. Anything the script can't match should go to a person, not a guess.
"""Record Zelle payments parsed from the bank's notification emails.
Reading the mailbox and parsing each email is up to you; this part takes
(sender, amount in cents, date, confirmation number) and records it.
"""
from urllib.parse import quote
from campfire import call, CampfireError
org = quote(call("GET", "/me")["data"]["organizationId"], safe="")
# Who each Zelle sender is: any member of the paying family will do, since
# the payment lands on the family account. Build this once (from the roster,
# or by hand) and add to it when a new family pays.
SENDERS = {
"JANE SMITH": "k3P9...",
}
def record(sender, amount_cents, paid_on, confirmation):
member_id = SENDERS.get(sender.strip().upper())
if member_id is None:
return "unknown sender, needs a person"
try:
result = call("POST", f"/organizations/{org}/payments", body={
"memberId": member_id,
"amountCents": amount_cents,
"method": "zelle",
"paidOn": paid_on,
"description": f"Zelle from {sender}",
"reference": confirmation,
})
except CampfireError as error:
if error.code in ("reference_conflict", "month_closed", "invalid_argument"):
return f"{error.code}, needs a person: {error}"
raise # 401/403: the token or its access needs fixing
# 201 means recorded now; 200 means it was already recorded. Both are done.
return "recorded " + result["data"]["id"]
print(record("Jane Smith", 4500, "2026-11-02", "ZL-3F9A2C71"))