Every change other than a payment goes through an import: you send rows, Campfire shows what it would do, and nothing is written until you apply. The app's spreadsheet import (Import & export in unit settings, then Choose a template) uses the same engine, so a script and a leader get the same results, and imports from both show in one history.
You need a token with imports:write (Import data in the app) and the permission the import type needs. The examples use the campfire.py helper from Getting started.
How an import works
POST /imports ──▶ previewed ──apply──▶ complete ──undo──▶ undone
│ └──▶ failed ──undo──▶ undone
└── an hour passes, or the unit changed ──▶ expired
- Preview. Send the rows. Campfire plans each one against the unit as it is now and saves the plan for an hour. Nothing in the unit changes.
- Read the rows. Each row has an outcome:
create,update,unchanged,skip,errororneeds_choice. Updates list every field's old and new value. - Apply, with your choices for the rows that need one. Campfire plans the rows again. If the unit changed since the preview so the plan would differ, it refuses with
409 plan_changedand you preview again. What gets written is always what you saw. - Undo, if you need to, within 30 days. Every type can be undone except members.
Rules every import follows
- Blank cells never clear anything. A blank leaves the field as it is. Clear a value in the app.
- Imports never delete records. Only undo removes what that import added.
- Re-running the same rows is safe. Rows match the same records again and show as
unchanged. - Permissions are checked per row. A row you can't write is a
skipwith reasonnot_allowed; the rest still imports. - Nobody is notified. No push, email or chat message comes from an import.
- Up to 5,000 rows and, for a file, 10 MB.
Import types
GET /import-types lists the types. GET /import-types/{type} lists each column's key, spreadsheet header, whether it is required, its format, allowed values where there is a fixed list, and inTemplate. GET /import-types/{type}/template.csv is the empty spreadsheet: just the header row, with the columns whose inTemplate is true. Ids Campfire fills into its own files have inTemplate: false: memberId in every type, and eventId and eventExternalId in attendance. An import still accepts them. These three need a token but no particular scope.
| Type | What it does | Needs | Undo |
|---|---|---|---|
members | Adds people, updates their details, links households, adds roles, marks departures. | directory:add_members or directory:edit_members (details below) | No |
activities | Camping nights, service hours and miles, one row per activity. | advancement:approve_rank, advancement:approve_merit_badges or advancement:manage_awards | Yes |
healthForms | Health form exam dates, one row per member. | health_forms:manage, and health forms not turned off for the unit | Yes |
roleHistory | Past leadership positions with start and end dates. | organization:manage_roles | Yes |
events | Calendar events, past or future, with the credits they give. | events:create to add, events:edit to update | Yes |
attendance | Who went to events that have ended. | events:sign_up_others | Yes |
advancement | Completion and awarded dates for ranks, merit badges, awards and adventures, and their requirements. | The sign-off permission for each item type | Yes |
accountHistory | Charges, credits and opening balances on family accounts. | finances:manage, Campfire Pro or a trial, and finances turned on | Yes |
GET /me lists the types you can import right now under capabilities.importTypes.
Dates
Date cells accept 2019-06-14 or 6/14/2019, and in an Excel file, a date cell. Event times have their own formats; see events.
Naming the member (every type but members and events)
A member reference is one of these, tried in order:
memberId: the Campfire id, from the roster read or the app's roster download. Blank templates leave it out.bsaMemberId: the BSA member ID on the member's profile, when exactly one member has it.firstNameandlastName(the first name can also be the nickname), narrowed bymemberType(youthoradult) when you give it. Former members match too; their history is still theirs.
No match is needs_choice with reason not_found. Several matches are needs_choice with reason ambiguous and the candidates.
activities
| Key | Format | Notes |
|---|---|---|
| member reference | Required. memberId, bsaMemberId, firstName, lastName, memberType. | |
type | camping, service, hiking, backpacking, cycling, aquatics | Required. |
amount | number above 0, at most 1000 | Required. Nights, hours or miles. |
date | date | Required. |
description | text | Up to 500 characters. |
externalId | text | Your own id for the row. With it, a re-run updates that record; without it, a row with the same member, type, amount, date and description matches the same record. Two rows with the same externalId in one import: the second is an error. |
Written as manual entries with no event attached, the way the TroopTrack import writes them. Don't import activities for events that already award credits in Campfire, or they count twice.
healthForms
| Key | Format | Notes |
|---|---|---|
| member reference | Required. | |
examDate | date | Parts A and B. The form expires one year later. Required when the member has no health form yet. |
partCExamDate | date | The Part C physical. Also marks the form as including Part C. |
Give at least one of the two dates. A member has one health form, so there is no externalId: each row updates that member's form, and a member on two rows is an error. An uploaded form file stays on the record.
members
| Key | Format | Notes |
|---|---|---|
memberId | Campfire id | Updates that member. Comes from the roster read or the app's roster download. |
externalId | text | Your own id for the person. Campfire remembers it, so a later import with the same value updates the same person. |
firstName, lastName | text | Required. |
memberType | youth or adult | Required. On an existing member, a different value changes whether they are an adult, and clears their den (now an adult) or the dens they lead (now a youth), as the app does. |
nickName, middleName, nameSuffix | text | |
dateOfBirth | date | |
email, mobilePhone, homePhone, workPhone | text | |
address1, address2, city, state, zip | text | A 4-digit ZIP (or ZIP+4) gets back the leading zero Excel drops. |
bsaMemberId | text | The BSA member ID. Unique in the unit: one another member already has, or an earlier row used, is an error. |
patrol | patrol name or id | Must already exist. If not, the row still imports and the patrol is left as is, with a note. |
den | den name or id | Scouts in a pack only; otherwise left as is, with a note. |
roles | role ids, separated by ; | Adds these roles, never removes one. Ids, never names: scoutmaster, treasurer, or your unit's own role ids. An unknown id is an error for the row. |
household | any label | Rows with the same label become one family: each adult is linked as a parent of each Scout. A household needs at least one adult and one Scout in the file. |
leftDate | date, not in the future | Marks the member as departed. Only for people with no app account. |
Matching for members: memberId, then an externalId from an earlier import, then bsaMemberId (exactly one member), then exactly one member with that name and type. Several name matches are needs_choice, settled with "new" or "skip" (see Apply). No match creates a new member, so look over every create row: a nickname mismatch would otherwise add a duplicate. The same new person twice in one file (same name and type, or same externalId), or the same existing member twice, is an error on the later row.
- Adding people needs
directory:add_members(ordirectory:edit_members). Changing anyone who already exists, and linking households, needsdirectory:edit_members. Therolescolumn needsorganization:manage_roles. - New members get
default_memberplus any roles in the row. Each added role gets a role history entry starting today, as Assign Roles in the app does. - Households link through the same code as linking a parent and Scout in the app. When two existing families would join, the preview carries a
merges_familieswarning. - A
leftDatefor someone who uses the app is askipwith reasonhas_account. Remove them in the app, one person at a time, so their access ends on purpose. - In an update,
changesshows old contact values only for members whose details you can see in the app; others shownull. - Member imports can't be undone. Adding people ripples into chats, families and roles. Check the preview; fix a wrong value with a corrected import.
roleHistory
| Key | Format | Notes |
|---|---|---|
| member reference | Required. | |
roleId | role id | Required. One of the unit's roles, by id: senior_patrol_leader, scoutmaster, or your unit's own role ids. Never names. |
startDate | date | Required. |
endDate | date | Required, not before the start and not in the future. |
externalId | text | Your own id for the row, so a re-run updates it. Without it, the same member, role and dates match the same entry. |
Needs organization:manage_roles. Adds past positions to the member's role history, as Previous Roles in the app does. It never changes anyone's current roles: those go in the roles column of a members import. A position with the same role and dates already added in the app shows as unchanged rather than being added twice. Undo keeps an entry edited in the app since.
events
| Key | Format | Notes |
|---|---|---|
externalId | text | Required. Your own id for the event. A re-run with the same value updates the same event. attendance rows can name the event by it. |
title | text | Required. Up to 200 characters. |
startAt, endAt | date and time, or a date | Required. See below. |
eventType | one of the app's event types | Backpacker, Campout, Community Service, Court of Honor, Cycling, Day event, Fund Raiser, Hike, Holiday, Long Cruise, Meeting, Order of the Arrow, Other, PLC Meeting, Regatta, Sail Training, Summer Camp, Swimming, Training, Trip. Anything else is an error for the row. |
location | name of a saved event location | A name Campfire doesn't have is left out, with a note on the row. |
description | text | Up to 5,000 characters. |
campingNights | whole number, 0 to 1000 | Credited to everyone marked attending, adults too. Blank on a Campout, Summer Camp, Backpacker or Long Cruise: the nights between the start and end dates, at least 1, as the app's event form fills it in. |
serviceHours | number, 0 to 1000 | Credited to everyone marked attending. Blank on Community Service: the hours between start and end. |
hikingMiles | number, 0 to 1000 | Credited to everyone marked attending. |
audience | den or patrol names, separated by ; | Blank means everyone. Dens only in a pack. A name Campfire doesn't have is an error for the row, so a typo can't turn the event into one for everyone. |
- Times.
2026-11-13 18:00,11/13/2026 6:00 PM, either with seconds (11/13/2026 18:00:00, as Google Sheets exports them), or ISO 8601 with an offset such as2026-11-13T18:00-05:00,+0500orZ. A time with no offset is read in the unit's time zone, or Eastern time if the unit has none set. A date alone in bothstartAtandendAtmakes an all-day event, from midnight on the first day to 11:59 PM on the last, as the app saves one. There is no separate all-day column. - The type is available with
events:createorevents:edit. Creating an event needsevents:create; updating one needsevents:edit. Undoing an events import needsevents:delete, as deleting an event in the app does. - An import only updates events an earlier import created. It never changes events made in the app.
- Imported events have no cost and ask for RSVPs. An import never cancels or deletes an event, and creating one notifies nobody.
attendance
| Key | Format | Notes |
|---|---|---|
eventTitle | text | The event's title in Campfire. Capitals don't matter. Template header "Event title". |
eventDate | date | The day the event started, in your unit's time zone. Template header "Event date". |
eventId | Campfire event id | From GET /events. Not in the template. Wins over everything else. |
eventExternalId | text | The externalId an events import used. Not in the template. Wins over title and date. |
| member reference | Required. | |
status | attending or not_attending | Required. The template header is "Attended". |
- Needs
events:sign_up_others. On an event that doesn't take RSVPs, a row also needsevents:edit. - In Campfire, attendance is the RSVP. Each row creates or updates that person's RSVP, and the event's credits follow, as when a leader marks attendance in the app. Don't also import
activitiesfor the same events, or the credits count twice. - Name each event by
eventId, byeventExternalId, or byeventTitleandeventDate, the way a leader knows it. By title and date, no event with that title starting that day isunknown_event, and two or more isambiguous_event: rename one in Campfire, then preview again. - The event must have ended and must not be canceled. Otherwise the row is an error with reason
not_endedorcanceled; an event Campfire can't find isunknown_event. - An event with a cost bills attendees, as the app's Attendance report does. Imported events never have a cost, so history never bills anyone. On an event that does, the preview carries a
charges_familieswarning with the number of families and the total inamountCents. Changing someone tonot_attendingon such an event removes their charge, as in the app, and the preview carries aremoves_chargeswarning the same way. Read them before you apply. If the event's cost changes after the preview, apply answers409 plan_changed; preview again.
advancement
| Key | Format | Notes |
|---|---|---|
| member reference | Required. | |
itemType | rank, meritBadge, award, adventure | Required. Adventures only in a pack. |
itemId or itemName | catalog id, or a name | One is required. Names such as "First Class", "Camping" or "Totin' Chip" are matched against Campfire's catalog. |
version | requirement version year | Needed on requirement rows when the catalog has more than one version of the item, which is most often a merit badge. |
requirement | requirement number (4a) or id | Blank means the item itself. |
dateCompleted | date | Required on a requirement row. An item row needs this, awardedDate, or both. |
awardedDate | date | Item rows only. |
- Each item type needs its sign-off permission: ranks and adventures
advancement:approve_rank, merit badgesadvancement:approve_merit_badges, awardsadvancement:manage_awards. Other rows are skipped withnot_allowed. - Rows for the same Scout and item make one record. There is no
externalId. - Blanks only. A date Campfire already has is never changed. The row says so in its
message. - Undo needs the sign-off permission for each item type it removes. Removing a completed rank recalculates the Scout's permissions, so sign-off rights the rank gave go with it.
- Nothing is earned on its own. An import writes exactly the dates in its rows. Completing every requirement doesn't date the item, and the preview doesn't point that out: give the item its own row. Linked rank requirements and elective slots are filled only when a leader signs off in the app.
accountHistory
| Key | Format | Notes |
|---|---|---|
| member reference | Required. The entry goes on the member's family account, or their own account if they have no family. | |
date | date | Required. Not in a month the treasurer has closed. |
kind | charge or credit | Required. A charge means the family owes more; a credit, less. |
amount | dollars: 45, 45.50, $1,250.00 | Required. Above 0. Unlike the rest of the API, this is dollars, not cents, because it comes from spreadsheets. |
description | text | Required. Up to 200 characters, such as "Opening balance". |
category | annual_dues, event_fee, camp_fee, equipment, fundraising_credit, discount, adjustment, reimbursement, other | Default other. |
externalId | text | Your own id for the row, so a re-run updates it. Without it, the same member, kind, amount, date and description match the same entry. |
- Needs
finances:manage, and Campfire Pro or a trial with finances turned on (403 feature_disabledotherwise). - Never payments. Rows become charges and credits, so old deposits don't count as cash on hand. To bring in a starting balance, one row per family dated at your cutover is enough.
- Closed months. A row dated in a closed month is an error, and so is a change to an existing entry in one. Undo is refused with
409 month_closedif any entry it would remove is in a month closed since. - Family balances are recalculated after the import, and again after undo. One import or undo runs at a time in a unit, so two can't set a balance at once.
Preview
POST /organizations/{orgId}/imports takes rows as JSON or as a file.
JSON rows
POST /api/v1/organizations/{orgId}/imports
Content-Type: application/json
{
"type": "activities",
"rows": [
{ "externalId": "tm-48213", "firstName": "Sam", "lastName": "Lee", "memberType": "youth",
"type": "camping", "amount": 2, "date": "2019-06-14", "description": "Summer camp" },
{ "externalId": "tm-48214", "bsaMemberId": "136680564",
"type": "service", "amount": 3, "date": "7/2/2019" }
]
}
- Use the column keys. An unknown key refuses the whole request.
- Values may be text, numbers or true/false. Empty strings and
nullare treated as blank. - Row numbers in the results are 1-based positions in your
rowslist.
A CSV or Excel file
Send the file as the body with Content-Type: text/csv or application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, and the type in the query string: ?type=members.
- The header row may use the keys (
firstName) or the template headers ("First name"). It must be in the first 10 lines. - Columns Campfire doesn't know are ignored and listed in a
ignored_columnswarning. - Row numbers in the results are the spreadsheet's line numbers, so the first row under the header is row 2.
- If Excel turned long ids into values like
1.36681E+08, the file is refused. Format those columns as Text, or start from a fresh download.
# The empty template, with the column headers Campfire expects
curl -sS -o health-forms.csv \
-H "Authorization: Bearer $CAMPFIRE_API_TOKEN" \
https://troopcampfire.app/api/v1/import-types/healthForms/template.csv
# Fill it in, then preview it
curl -sS -X POST "https://troopcampfire.app/api/v1/organizations/$ORG_ID/imports?type=healthForms" \
-H "Authorization: Bearer $CAMPFIRE_API_TOKEN" \
-H "Content-Type: text/csv" \
--data-binary @health-forms.csv
The response
201 with the import. Its id starts with imp_.
{
"data": {
"id": "imp_2kx9q81b04f7c2a9",
"type": "activities",
"source": "api",
"status": "previewed",
"createdAt": "2026-11-02T14:04:05.000Z",
"createdByName": "Pat Kim",
"expiresAt": "2026-11-02T15:04:05.000Z",
"summary": { "rows": 412, "create": 380, "update": 0, "unchanged": 20,
"skip": 8, "error": 2, "needsChoice": 2 },
"warnings": [],
"appliedAt": null,
"finishedAt": null,
"undoUntil": null,
"undoneAt": null,
"undoResult": null,
"error": null
},
"requestId": "..."
}
warnings carries what you must see before applying: ignored_columns (with columns), merges_families (with count), and for attendance charges_families and removes_charges (with count and amountCents).
Read the rows
GET /organizations/{orgId}/imports/{importId}/rows is a paginated list in row order. Add ?outcome=needs_choice (or any other outcome) to see only those. Rows hold the cells you uploaded, so only the person who ran the import can read them (anyone else gets 404), and they are deleted 31 days after the preview or apply. name is the first and last name as the row gives them.
{ "row": 2, "outcome": "create", "recordId": "imp_5d1e..." }
{ "row": 13, "name": "Alex Kim", "outcome": "needs_choice", "reason": "ambiguous",
"candidates": [
{ "memberId": "a1...", "firstName": "Alex", "lastName": "Kim", "isAdult": false },
{ "memberId": "b2...", "firstName": "Alex", "lastName": "Kim", "isAdult": true } ] }
{ "row": 14, "name": "Sam Lee", "outcome": "update", "recordId": "imp_9c0a...",
"changes": { "amount": { "from": 2, "to": 3 } } }
{ "row": 15, "name": "Jo Park", "outcome": "error", "reason": "invalid_value", "column": "date",
"message": "Date must look like 2019-06-14 or 6/14/2019." }
| Outcome | Reasons you may see |
|---|---|
create, update, unchanged | None. recordId is the record. message may carry a note, such as a patrol or event location that was left as is, or an advancement date Campfire already had. |
needs_choice | not_found, ambiguous (with candidates) |
skip | not_allowed, has_account, skipped (you chose to leave it out), no_choice (after apply: no choice was given) |
error | missing_value, invalid_value (with column), unknown_member, duplicate, and for attendance unknown_event, ambiguous_event, not_ended, canceled |
Every error row, and a skip for not_allowed or has_account, has a message you can show to a person, and so does an ambiguous members row. needs_choice rows have reason and candidates. After apply, each row holds its final outcome. After undo, "undo": "kept" marks an added record that was left in place.
Apply
POST /organizations/{orgId}/imports/{importId}/apply with your choices:
{ "choices": { "13": "a1...", "22": "skip" } }
- Keys are row numbers, as strings. Only rows waiting for a choice may appear; anything else refuses the request.
- For every type that names a member (all but
membersandevents), a value is a member id (one of thecandidates, or any member of the unit for anot_foundrow), or"skip"to leave the row out. - For
members, a value is only"new"(add the row as a new person) or"skip". A member id is refused with400: it would write changes nobody saw in the preview. To update one of the people the name matched, put their Campfire ID in the row'smemberIdand preview again. The row'smessagesays so too. - Rows still waiting with no choice are skipped. Send
{}when there is nothing to choose.
Apply runs inside the request. When it finishes within about a minute it answers 200 with the finished import, status: "complete", a final summary, and undoUntil for types that can be undone. Calling apply again doesn't apply twice; it returns the import as it stands. Only the person who made the preview can apply it; anyone else gets a 403. A retry may send the same choices again.
Big imports outlast the response
Requests to the API pass through a hosting layer that gives up after about 60 seconds with a 504, while the apply keeps running for up to 9 minutes. If you get a 504 or your own timeout, the import is not lost: poll GET /imports/{importId} every few seconds until status is complete or failed. Don't preview again in the meantime.
| Response | What to do |
|---|---|
409 plan_changed | Something a row would write changed in the unit since the preview. Nothing was written and the import becomes expired; applying it again keeps answering 409 plan_changed. Preview the rows again. |
410 import_expired | The preview is more than an hour old. Preview again. |
409 import_running | Another import is running in the unit. Try again when it finishes. |
504 or a timeout | The apply is still running. Poll the import, as above. |
| Any other error | If the apply stopped partway, the import shows status: "failed" with an error message. An apply that died without a word reads as failed 10 minutes after it started. For every type but members, undo it, then preview and apply again. For members, preview the same rows again: people already added match the earlier rows instead of being added again. |
Undo
POST /organizations/{orgId}/imports/{importId}/undo with an empty body answers 200 with status: "undone" and undoResult: how many records were removed and kept.
undone = call("POST", f"{base}/{imp}/undo", body={})["data"]
print(undone["status"], undone["undoResult"]) # undone {'removed': 380, 'kept': 0}
- Undo removes the records the import added, unless someone has changed or used them in the app since: a corrected amount, a health form uploaded again or given notes, an advancement record with a new date, sign-off or note, an RSVP changed, or an event that was edited, canceled, or has RSVPs, rides, a checklist, an event chat or files. Those are kept and counted in
kept. Undo an attendance import before the events import it used. - Undoing attendance removes the charges its RSVPs caused and the camping nights and service hours they earned. Manual activity entries stay. Undoing account history recalculates the families' balances.
- Changes the import made to records that already existed stay. Fix a wrong value with a corrected import.
- Only a
completeorfailedimport from the last 30 days can be undone (undoUntil, counted from when apply started). Anything else, and everymembersimport, is409 not_undoable. - Undo needs a permission the import type needs, but not the feature: you can undo a health form import after health forms were turned off. It also needs the permission to delete each record it removes (
events:deletefor events, the sign-off permission for advancement); otherwise it is403 access_deniedand nothing is removed. Undoing an import that is already undone returns it unchanged. - A long undo can end in a
504like a long apply (see above). It carries on; poll the import untilstatusisundone.
History
GET /organizations/{orgId}/imports lists imports newest first, from the app (source: "app") and the API ("api"). Like the app's import history, it shows every import in the unit to anyone whose role includes some kind of import; anyone else gets a 403. GET /organizations/{orgId}/imports/{importId} reads one. status is previewed, expired, running, complete, failed or undone.
Limits
- Previews: 20 an hour per account.
- Applies and undos together: 10 an hour per unit, and one running at a time.
- 5,000 rows and 10 MB per import. A preview lasts an hour.
A whole script
Preview activity rows, show the problems, ask a person to settle each open row, then apply.
"""Bring in activity history: preview, settle the open rows, apply."""
from urllib.parse import quote
from campfire import call, CampfireError
import time
org = quote(call("GET", "/me")["data"]["organizationId"], safe="")
base = f"/organizations/{org}/imports"
rows = [
{"externalId": "tm-48213", "firstName": "Sam", "lastName": "Lee", "memberType": "youth",
"type": "camping", "amount": 2, "date": "2019-06-14", "description": "Summer camp"},
{"externalId": "tm-48214", "bsaMemberId": "136680564",
"type": "service", "amount": 3, "date": "2019-07-02"},
]
preview = call("POST", base, body={"type": "activities", "rows": rows})["data"]
imp = quote(preview["id"], safe="")
print("Preview:", preview["summary"], preview["warnings"])
def rows_with(outcome):
query = {"outcome": outcome, "limit": 50}
while True:
page = call("GET", f"{base}/{imp}/rows", query=query)
yield from page["data"]
if page["nextCursor"] is None:
return
query["cursor"] = page["nextCursor"]
for row in rows_with("error"):
print(f"Row {row['row']}: {row.get('column', '')} {row['message']}")
choices = {}
for row in rows_with("needs_choice"):
candidates = row.get("candidates", [])
print(f"Row {row['row']} ({row['reason']}):")
for number, person in enumerate(candidates, 1):
kind = "adult" if person["isAdult"] else "youth"
print(f" {number}. {person['firstName']} {person['lastName']} ({kind})")
pick = input(" Number to use, or Enter to leave the row out: ").strip()
choices[str(row["row"])] = candidates[int(pick) - 1]["memberId"] if pick else "skip"
def wait_until_finished():
"""Poll the import after a 504 or timeout: the apply is still running."""
while True:
time.sleep(5)
current = call("GET", f"{base}/{imp}")["data"]
if current["status"] not in ("previewed", "running"):
return current
if input("Apply? [y/N] ").strip().lower() == "y":
try:
done = call("POST", f"{base}/{imp}/apply", body={"choices": choices})["data"]
except (CampfireError, OSError) as error:
if isinstance(error, CampfireError) and error.status != 504:
raise
done = wait_until_finished()
print(done["status"], done["summary"], done["error"] or "")
print("Undo possible until", done["undoUntil"])
Example: past events and who went
Bring in last year's outings from another system, then who attended each one. The events import comes first, because attendance names each event by the externalId it was imported with, or by its title and start date. Each import is previewed, checked and applied on its own.
"""Import past events, then attendance on them."""
from urllib.parse import quote
from campfire import call
org = quote(call("GET", "/me")["data"]["organizationId"], safe="")
base = f"/organizations/{org}/imports"
def preview_and_apply(import_type, rows):
preview = call("POST", base, body={"type": import_type, "rows": rows})["data"]
imp = quote(preview["id"], safe="")
print(import_type, preview["summary"])
for warning in preview["warnings"]:
print(" Warning:", warning)
problems = call("GET", f"{base}/{imp}/rows", query={"outcome": "error", "limit": 50})["data"] # first 50
for row in problems:
print(f" Row {row['row']}: {row.get('column', '')} {row['message']}")
if input(f"Apply the {import_type} import? [y/N] ").strip().lower() != "y":
raise SystemExit("Stopped before applying.")
return call("POST", f"{base}/{imp}/apply", body={"choices": {}})["data"]
preview_and_apply("events", [
{"externalId": "tm-ev-2025-10", "title": "October Campout", "eventType": "Campout",
"startAt": "2025-10-17 18:00", "endAt": "2025-10-19 11:00",
"location": "Camp Sequassen", "campingNights": 2},
{"externalId": "tm-ev-2025-11", "title": "Food drive", "eventType": "Community Service",
"startAt": "2025-11-08", "endAt": "2025-11-08", "serviceHours": 3}, # all day
])
preview_and_apply("attendance", [
{"eventExternalId": "tm-ev-2025-10", "firstName": "Sam", "lastName": "Lee",
"memberType": "youth", "status": "attending"},
{"eventExternalId": "tm-ev-2025-10", "bsaMemberId": "136680564", "status": "attending"},
# The way a leader names it: title and the day it started
{"eventTitle": "Food drive", "eventDate": "2025-11-08", "firstName": "Sam",
"lastName": "Lee", "memberType": "youth", "status": "not_attending"},
])
- The times have no offset, so they are read in your unit's time zone. The food drive has a date alone at both ends, so it is an all-day event.
- Sam and the second Scout get the October campout's two camping nights from the event's credits, as if a leader had marked them attending. Don't also import those nights as
activities. - The last row names the food drive by title and date, as a spreadsheet from the template does. That works for any event in Campfire, including ones made in the app, as long as no other event with that title started the same day.
- These events have no cost, so no family is billed. Attendance on an existing event with a cost would show a
charges_familiesorremoves_chargeswarning before you apply. - This example leaves out rows that need a choice (they are skipped) and the 504 handling from the script above. Add both for a big import.