Hand off a donor session
Your backend opens a session and sends the donor to the hosted flow. The donor verifies their phone, accepts the required agreements and completes their enrollment there. Your partner key stays on your backend.
Before you start
You need a church ID associated with your partner, a gift mode and value, and your sites registered with PUT /v1/allowed_origins (see Open the flow on your page). Donors can complete paycheck giving only at a church Roster has verified; before that, the hosted flow tells them paycheck giving isn't enabled for this church yet (see Complete a church's legal setup). The phone number is optional; when provided, it must use E.164 format. For sandbox testing, use a test donor: a test number, any adult date of birth and the code 000000.
curl --fail-with-body -X POST "$ROSTER_BASE_URL/v1/donor_sessions" \
-H "Authorization: Bearer $ROSTER_KEY" \
-H "Idempotency-Key: enrollment-attempt-001" \
-H "Content-Type: application/json" \
--data @donor-session.json
Create donor-session.json locally with this shape and your scenario's values. Keep files containing donor information out of version control and delete them after use.
{
"church_id": "ch_REPLACE_WITH_YOUR_CHURCH_ID",
"donor_phone": "+12025550100",
"mode": "pct",
"value": 10
}
There is no return address: the flow opens on your page and never sends the donor to another address. A redirect_url sent by an older integration is ignored.
The phone above is a sandbox test number: its code is 000000. With mode: "pct", value is the percentage of each paycheck, a whole number from 1 to 35 (12, never 12.5): value: 10 means 10 percent. With mode: "fixed", the value is in dollars, not cents, from 1 to 4,500 and in whole cents (12.50, never 12.345). A value outside these rules receives a 400 naming the rule. Above 20 percent, or above $3,000 per paycheck, the donor confirms the amount once more on the review screen, and may be asked for a new code, before the gift is created. Consult the API Reference for all validation rules.
Pre-fill what you already know
If the donor is already signed in to your product, send what you know in the optional donor object. Every field is optional. It only pre-fills the hosted flow: the donor sees the values, corrects anything that is wrong and moves on. What Roster stores is what the donor confirms and verifies, never the pre-fill itself.
{
"church_id": "ch_REPLACE_WITH_YOUR_CHURCH_ID",
"donor_phone": "+12025550100",
"donor": {
"given_name": "Ana",
"family_name": "Souza",
"date_of_birth": "1990-05-17"
},
"mode": "pct",
"value": 10
}
In the sandbox, use a test number and pick a date of your own: test numbers are shared by every sandbox account, and the same number and date is the same test donor for every partner. The phone and the date of birth appear already filled when the flow asks for them. The name is used only when identity verification cannot pre-fill it itself; when it can, the verified record is what the donor confirms. Email and mailing address are not accepted in donor: identity verification supplies them when it can, the donor types what it cannot, and the donor confirms both on screen. In the sandbox, verification supplies the fictional address but never an email, so the test donor always types one. A request that includes either one receives a 400.
Like donor_phone, donor travels in the request body only. No read returns the values you sent, and they are never placed in a URL, a log or an event; they are stored in the protected session record. What can come back later is the profile the donor verified, as described in Observe the outcome.
Your reference and fund
Send reference, your platform's identifier for the donor, and fund, the fund or designation for the gift, when you create the session. Both are optional.
{
"church_id": "ch_REPLACE_WITH_YOUR_CHURCH_ID",
"donor_phone": "+12025550100",
"mode": "pct",
"value": 10,
"reference": "donor_8841",
"fund": "General Fund"
}
Both come back as you sent them: in GET /v1/donor_sessions/{id}, in the enrollment.created event and in GET /v1/enrollments/{id}. Use them to file the gift in your records.
reference accepts letters, digits, ., _, : and -, up to 64 characters. It stays in the enrollment's history, so it must never be an email address, phone number or name. fund accepts up to 60 characters. Roster does not use fund to route money: the gift always goes to the church of the session.
Split a gift among funds
When your platform lets the donor split one gift among several funds of the same church, send funds instead of fund: one entry per fund, with your identifier in id, an optional name and the percent of the gift. Percents have at most two decimal places and add up to exactly 100; up to 10 funds, and identifiers do not repeat.
{
"church_id": "ch_REPLACE_WITH_YOUR_CHURCH_ID",
"mode": "pct",
"value": 5,
"reference": "donor_8841",
"funds": [
{ "id": "building", "name": "Building Fund", "percent": 66.67 },
{ "id": "missions", "name": "Missions", "percent": 33.33 }
]
}
For a fixed gift (mode: "fixed"), you can send the split in dollars instead: an amount per fund, with at most two decimal places, adding up exactly to value. Use one form for every fund, percents or amounts, never both.
{
"church_id": "ch_REPLACE_WITH_YOUR_CHURCH_ID",
"mode": "fixed",
"value": 50,
"reference": "donor_8841",
"funds": [
{ "id": "building", "name": "Building Fund", "amount": 30 },
{ "id": "missions", "name": "Missions", "amount": 20 }
]
}
A percentage gift takes only percents: its dollar amount changes with each paycheck, so amounts would not add up; amount with mode: "pct" receives a 400. If the donor later changes a fixed gift, each fund keeps its share of the split: 30 and 20 on a USD 50 gift become 36 and 24 on a USD 60 gift. Every donation.updated then carries funds with the amount of each fund in cents, adding up exactly to the donation. The split comes back as you sent it in the session, in the enrollment.created event and in GET /v1/enrollments/{id}. Like fund, it never changes where the money goes: every fund is the same church bank account. Sending both fund and funds receives a 400.
Preview the donor experience in the partner portal
The Donor Flow page is a visual demonstration, not a session creator. Use the API Reference playground to exercise donor-session requests with approved sandbox data.
Treat the URL as a one-time handoff
The first 201 response includes the hosted url, which expires in 30 minutes. Store the session ID for support and correlation. Pass the URL to your page only to open the flow, as shown below; exclude it from analytics and logs, and do not share it in an AI conversation.
Put the attempt in the
Idempotency-Key, and check forurlbefore you open the flow. The key identifies one attempt to hand a donor a link, not the donor. A key built only from who the donor is (donor-8841, or your church and donor IDs together) turns every later attempt for that donor into a replay of the first, and a replay answers200withouturl: that link is gone. Build the key from your own attempt record, such asdonor-8841-attempt-2. Reuse it only to retry that same attempt after an ambiguous response; use the next attempt number when the donor needs a new link. Treat a response withouturlas "no link to open", never as a page to load.
A replay returns session information but does not disclose the URL again. GET /v1/donor_sessions/{id} does not recover it either. If the initial response was lost, first look up the session information available to you and check the donor's progress; create a fresh attempt only when a new handoff is needed. Do not loop over new idempotency keys after an ambiguous response.
A 200 without url is that replay: the session your key created already exists, and its link will not come back, neither from another replay nor from GET /v1/donor_sessions/{id}. Its status tells you where the donor is. opened or completed means the link was already used: follow the outcome on your webhook or the feed. created means nobody has opened the link yet. If the donor is still waiting on your page, create one new session with the next attempt in a new Idempotency-Key and open its url. The session you could not open needs no cleanup: it stays created until its expires_at, and then reads as expired.
Open the flow on your page
The donor flow opens as a window over your page, or inside an element of your page. It never opens a new browser tab. Your giving button opens it with roster.js and the session url, as shown below. The data-roster-church snippet in the Churches section is for a church's own website: gifts made through it do not reach any platform's feeds, including yours.
-
Register the sites where your pages open the flow: exact HTTPS origins such as
https://give.example.org, with no path or wildcard, up to 10. UsePUT /v1/allowed_originsor the API keys page of the partner portal. The PUT replaces the whole list: read it withGET, then send the new list inoriginsand the list you read inexpected_origins. If someone changed the list in the meantime, you get409and nothing changes: read it again and reapply your change. Only registered sites can show the flow inside their pages, and a site belongs to one partner at a time. To test from your own computer, the sandbox also acceptshttp://localhostwith its port, such ashttp://localhost:3000; it belongs to no partner, and production refuses it.On a site that is not on your list the flow does not open. In the sandbox, a
localhostport you did not register shows This link can't be opened; any other site shows the browser's blocked-frame page, with aframe-ancestorserror in the console. Your page receives no completion or cancel event. The link is not spent: ifGET /v1/donor_sessions/{id}still sayscreated, add the site withPUT /v1/allowed_originsand open the sameurlagain beforeexpires_at. A site you save reaches the flow within a few minutes.
Read the list, then send the new one together with the list you read:
curl "$ROSTER_BASE_URL/v1/allowed_origins" \
-H "Authorization: Bearer $ROSTER_KEY"
# {"origins":["https://give.example.org"]}
curl -X PUT "$ROSTER_BASE_URL/v1/allowed_origins" \
-H "Authorization: Bearer $ROSTER_KEY" \
-H "Content-Type: application/json" \
-d '{"origins":["https://give.example.org","https://www.example.org"],"expected_origins":["https://give.example.org"]}'
expected_origins is optional, but without it the PUT replaces whatever is saved, including a colleague's change.
- Your backend creates the donor session and passes its
urlto your page. - Your page loads
roster.jsfrom the host of thaturl, at/v1/roster.js:https://flow.sandbox.rostergiving.com/v1/roster.jsin the sandbox, andhttps://flow.rostergiving.com/v1/roster.jsin production (it opens when Roster releases your production workspace). The sandbox and production flows each serve their own script, and a session only opens with the script of its own environment. The address stays the same for every version of the script. To see howroster.jschanges, or to stay on the previous major withdata-roster-channel, read the changelog. - When the donor clicks your giving button, open the session:
<button id="give" type="button">Give from your paycheck</button>
<script>
const give = document.getElementById("give");
// roster.js did not load (onerror below), or the flow is unavailable.
function disableGiving() {
give.disabled = true;
}
// event.detail.reason is "maintenance" or "error".
window.addEventListener("roster:unavailable", disableGiving, { once: true });
// roster.js loads in two steps: `Roster` exists once `roster:ready` fires.
function withRoster(callback) {
if (window.Roster) callback(window.Roster);
else
window.addEventListener("roster:ready", () => callback(window.Roster), {
once: true,
});
}
give.addEventListener("click", () => {
withRoster((Roster) =>
Roster.open({
session: sessionUrl, // the url from POST /v1/donor_sessions
onEvent: (event) => {}, // every state event, as sent by the flow
onComplete: (event) => {}, // the gift is active (see below)
onCancel: () => {}, // only when the flow sends flow_cancelled, which is rare
onClose: () => {}, // the window closed: ×, Esc, a click outside it, or close()
}),
);
});
</script>
<script src="https://flow.sandbox.rostergiving.com/v1/roster.js" async onerror="disableGiving()"></script>
roster.js is a small loader that fetches the current flow and then defines window.Roster, so calling Roster.open as soon as the script tag runs can find nothing yet. The loader fires roster:ready on window once Roster.open and Roster.mount exist; a listener added after that moment never hears it, which is why the helper above checks window.Roster first. If the flow cannot load, it fires roster:unavailable instead, with event.detail.reason set to "maintenance" or "error": disable or hide your giving button then, since Roster will not appear. If roster.js itself cannot load (offline, a wrong host, or blocked by an extension or by your page's Content Security Policy), neither event fires: the script tag's error event, handled by onerror above, is your signal.
If your page sends a Content-Security-Policy, allow the flow host, the host of the session url, in script-src (for roster.js and the flow script it loads), in connect-src (the loader reads /v1/manifest.json) and in frame-src (the window itself). Without connect-src, the loader fires roster:unavailable with reason: "error".
Roster.open returns { close }. To place the flow inside an element of your page instead of a window, use Roster.mount(element, { session: sessionUrl }), which returns { unmount } and accepts the same onEvent, onComplete and onCancel. A URL from another environment, or anything other than a session url, throws an error before anything opens; for another environment's session, the error names the host to load roster.js from.
The width and corners you set in the Donor Flow settings apply to the window as well as to the screens.
Events carry state only, never donor information: flow_started, resize, flow_completed, flow_cancelled and flow_close_requested. They help your page react; they do not prove an enrollment. Use events for that. flow_started can arrive more than once in a journey.
Today flow_completed arrives only when the flow reaches its last screen with the gift active, and it carries no result. A journey that ends in review, on the payroll form for the donor's employer, or on any other screen sends no flow_completed: the window stays on that screen until the donor closes it. Do not wait for onComplete to thank the donor; follow the session and the enrollment as described in Observe the outcome.
Closing the window (×, Esc, a click outside it, or close()) calls only onClose: it does not send flow_cancelled or call onCancel. onCancel runs only when the flow itself sends flow_cancelled, which is rare. An onClose without an onComplete before it does not tell you how the journey ended: the donor may have left, or reached review or the payroll form. Read GET /v1/donor_sessions/{id} and follow the enrollment through events.
In a mobile app, open the session url in a web view or in the in-app browser, with embed=app and your app's return link added: {url}?embed=app&return=yourapp%3A%2F%2Fgiving%2Fdone. The return link must use your app's own scheme; an https return link is ignored, and the flow then never returns to your app. When the gift is active, the flow opens your return link with roster=completed added, and nothing else. roster=cancelled comes only from the Esc key, which phones rarely have. A journey that ends in review or on the payroll form does not open the return link, and a donor who closes your web view sends nothing: handle that close in your app.
Recovery in the hosted flow
Closing the payroll window does not activate an enrollment. On return, the hosted flow checks the server's state. If connectivity is lost, the donor can resume after the connection returns; required agreements still apply. Avoid creating a replacement donor session solely because the payroll window closed or one response was delayed. Status checks do not create a payroll session. If a recovery needs a new session, the donor starts it with Open my payroll; a donor who had already signed in to their payroll picks up where they left off without signing in again. A timed-out request is not automatically repeated by background polling.
When embedding with Roster.mount, keep one active donor flow per page.
Mounting a new flow replaces the previous iframe. Several donation buttons
can remain on the host page; opening one starts the active flow.
Roster.open works the other way: it shows one window at a time. While one
is open, another Roster.open opens nothing, warns in the console and returns
a close that does nothing, and its onClose never runs. Close the first
window before opening another session.
Automated browser tests
For automated browser tests: Roster.open places the flow's iframe inside an
open shadow root, so document.querySelectorAll("iframe") on your page does
not find it; reach it through the shadowRoot of the [data-roster-overlay] element on your page, or through
your test tool's frame list.
Inside the flow, select controls by their stable attributes, never by their
text, which can change. Each screen is an element carrying data-screen
(for example [data-screen="link-gone"]). Buttons and links, and only they,
carry data-action; inputs, checkboxes and lists carry data-field. The two
tables below are the complete list: every screen the flow shows, and every
name it emits, on the screen that emits it. Several controls appear only in
the situation the table describes, so do not replay a fixed sequence: wait for
the screen you expect, then act on what it shows.
Donor flow (the session url)
Screen (data-screen) |
When it appears | Buttons and links (data-action) |
Inputs (data-field) |
|---|---|---|---|
identity |
The first screen of a session: mobile phone and date of birth | identity-continue, identity-back |
phone, dob |
code |
The confirmation code. code is one input drawn as six boxes, and typing or pasting the sixth digit continues by itself, so a test does not need to click code-continue after filling it. code-resend stays disabled for 30 seconds after each code is sent. If the flow cannot restore the journey, it shows only Try again (code-continue). Call me with the code appears only for a donor returning to the journey |
code-continue, code-resend, code-back, code-by-voice |
code |
review |
The donor checks the details and accepts the agreements. email appears when verification does not supply one, which is always the case in the sandbox. agree-amber appears above 20 percent or above $3,000 per paycheck, and Continue needs it ticked. Each detail the donor can correct (first name, last name, address, email) has a pencil button, review-edit, with the row in data-row (given, family, address, email). It opens that row in place with its fields (pii-given, pii-family, the four address pii- fields, or email), Save (review-row-save) and Cancel (review-row-cancel); Continue and another pencil also keep an open row. The six pii- fields also appear together when verification cannot fill in the name and address, or when a correction is saved and the address from verification is incomplete |
review-continue, review-edit, review-row-save, review-row-cancel |
email, agree-bundle, agree-amber, pii-given, pii-family, pii-line1, pii-city, pii-state, pii-zip |
reauth |
A gift above 20 percent or above $3,000 per paycheck can ask for a new code here before it is created. dob appears only when the flow does not have the date of birth |
code-continue, code-resend, code-by-voice |
code, dob |
another-church |
The donor already gives to another church: add a second gift or move the current one here | gift-add, gift-move |
— |
percent |
The amount, after gift-add or gift-move, or after identity-back. The donor types a whole number in amount. amount-mode switches the unit: data-mode="pct" (the default) is a percentage from 1 to 35, and an empty field continues with 10 percent; data-mode="fixed" is a dollar amount per paycheck from 1 to 4,500, in whole dollars, and must be typed. pct-continue continues in either unit |
amount-mode, pct-continue, go-manage |
amount |
confirm |
The recurring authorization. Disabled while Roster prepares the donor's account (see below). auth-doc-close closes the authorization document that auth-doc-open shows; confirm-back appears when the donor reached this screen from review in this visit |
confirm-authorize, auth-doc-open, auth-doc-close, confirm-back |
agree-payroll-auth |
connect |
The payroll connection. In the sandbox it has no button: it waits for POST /sandbox/simulate/payroll-connected, then moves to success by itself. The two actions are production only |
connect-open, go-hr-form |
— |
success |
The gift is active | close-flow, go-manage |
— |
in-review |
The donor's account is in review. The buttons appear only when the screen asks for a photo of an ID; docv-back while that photo is being checked. Without buttons, this screen does not check again by itself |
docv-open, docv-later, docv-back |
— |
docv-deferred |
The donor chose to add the ID later | — | — |
declined |
Closed: the gift could not be set up | — | — |
hr-form |
Production only, after go-hr-form: the donor sets the gift up with the payroll office |
hr-email, hr-copy, hr-download, hr-done |
— |
hr-form-done |
After hr-form |
forms-done |
— |
church-not-enabled |
Roster has not verified the church yet | go-manage |
— |
church-unavailable |
The church is not taking new gifts | go-manage |
— |
welcome-back |
The donor confirms phone and date of birth to continue, for example after the session timed out | welcome-continue |
phone, dob |
resume-expired |
The journey cannot resume from here; the donor starts again | go-restart |
— |
link-gone |
The session url cannot be opened (see Reopening the hosted link) |
— | — |
no-church |
The flow has no church to open | — | — |
Manage flow (the manage session url)
Screen (data-screen) |
When it appears | Buttons and links (data-action) |
Inputs (data-field) |
|---|---|---|---|
recognize |
The donor types phone and date of birth. leave appears when roster.js or your app opened the flow |
recognize, go-give, leave |
phone, dob |
code |
The confirmation code. As in the giving flow, code is one input drawn as six boxes, and the sixth digit continues by itself, so a test does not need to click verify after filling it. resend stays disabled for 30 seconds after each code is sent |
verify, resend, by-voice, back |
code |
home |
The gift and what the donor can do with it. Change amount is change: the first click reveals amount-mode and new-value and amber-ack. amount-mode switches the unit of the new amount, as on percent: data-mode="pct" is a percentage from 1 to 35, data-mode="fixed" a dollar amount per paycheck from 1 to 4,500, both in whole numbers; it starts on the gift's current unit, and switching clears new-value. A second change with the new value presents the new Recurring Transfer Authorization, with value-authorization-accepted and confirm-change. resume appears on a paused gift, reconnect on a gift paused because the payroll connection stopped, select-gift and gift for a donor with two or more gifts. refund-account appears when a refund to the donor is waiting for a bank account: it opens the bank sign-in window. When the bank shares more than one account, refund-account-choice lists them with the first one chosen, and refund-account-use saves the chosen one |
change, amount-mode, confirm-change, pause, resume, cancel, reconnect, select-gift, finish-disconnect-previous, see-form, check-payroll, refund-account, refund-account-use, back |
new-value, amber-ack, value-authorization-accepted, pause-months, gift, refund-account-choice |
no-gift |
No active gift for this phone and date of birth | back |
— |
cancel-pending |
A cancellation still waiting for the payroll deduction to be removed | finish-disconnect, see-form, check-payroll, refund-account, refund-account-use, back |
refund-account-choice |
done |
Right after Cancel my gift | finish-disconnect, see-form, check-payroll |
— |
payroll-form |
What to send to the payroll office (see-form) |
form-email, form-copy, back |
— |
complete |
Nothing left to do. close appears when roster.js or your app opened the flow |
close |
— |
declined |
Closed: the gift could not be set up | finish-disconnect, check-payroll, refund-account, refund-account-use, back |
refund-account-choice |
check-payroll is in a panel below the screen, not on the screen itself. In
the sandbox it appears after reconnect, finish-disconnect or
finish-disconnect-previous: play the payroll provider with the matching
simulator, then choose Check connection status.
Waiting for the flow
-
Before the first screen, the frame can show a loading message with no
data-screen. Wait for a[data-screen]element before acting. -
While a request started by a click is running, the flow's buttons carry
disabled. -
On
confirm, wait for the controls to be enabled. Roster prepares the donor's account in the background once identity verification passes. Until the account and this donor's authorization document are ready,agree-payroll-auth,confirm-authorizeandauth-doc-opencarrydisabled, and the screen shows Preparing your account…. The flow checks every few seconds and removesdisabledas soon as they are ready; the first time a donor reaches this screen, that can take a while. Wait for[data-field="agree-payroll-auth"]:not([disabled])with a timeout of a couple of minutes, rather than a fixed delay or your test tool's default timeout: a disabled checkbox ignores clicks. Then tick it and clickconfirm-authorize. -
in-reviewwithout buttons does not move on by itself, even after the review is decided. Bring the donor back with a new session, as described in Identity check.
Until the flow opens, the frame's address carries the
one-time session token (in the manage flow, for the whole visit), so keep
browser traces, session replays and network logs that record frame addresses
out of shared storage, as you do with the url itself.
Observe the outcome
The flow ends where it opened: in the window or element on your page, or in your app, which receives roster=completed as described above. onComplete and that app return are navigation conveniences. They are not proof of completion, activation, or a donation.
partner_session.completed arrives when the session creates the donor's enrollment, at the same moment as enrollment.created: right after the donor confirms their details and accepts the agreements. It carries the enrollment_id of that recurring gift. It does not mean the donor finished the flow: the identity check, the recurring authorization and the payroll connection come after it, and the donor can stop at any of them. onComplete on your page comes later, with the gift active, which enrollment.activated confirms for a new gift (for a donor who already gives to your church, see A donor who already gives elsewhere). From then on, GET /v1/donor_sessions/{id} returns the same enrollment_id and the donor verified in the session: name, email, phone and mailing address, the same fields as donor.updated. Birth date, government identifiers, documents and bank details are never included, and donor is null when the verified profile is not available. Today the verified donor is returned only in the sandbox, where financial integration is enabled; production returns null.
Use events to follow the enrollment. For an enrollment.* event, entity_id is the enrollment ID; do not assume every payload contains enrollment_id. Read GET /v1/enrollments/{id} for the current state when reconciling delayed notifications.
enrollment.activated confirms an active enrollment. It does not prove a deposit occurred or a gift reached the church. Keep enrollment status separate from financial reporting.
Changes and cancellation belong to the donor's authenticated manage flow. The Partner API and its MCP do not let a partner perform these acts on a donor's behalf. You can open that flow from your account area, as described next.
Let donors change or cancel from your account area
A donor who gives through your platform can change or cancel the gift from your account area, in the same kind of Roster window. You do not install the church cancel button for this: your page opens a manage session.
- Save the address of that page as
manage_urlin your workspace settings (PUT /v1/flow-settings, withoutchurch_id). It must be an HTTPS address on one of your registered sites. Emails to donors who came through you then link "change or cancel" to that page instead of to Roster. - When the donor asks to change or cancel, your backend creates a manage session for the gift:
POST /v1/manage_sessions
Authorization: Bearer sk_test_...
Idempotency-Key: manage-enr_REPLACE-1
Content-Type: application/json
{ "enrollment_id": "enr_REPLACE_WITH_THE_ENROLLMENT_ID" }
- Your page opens the returned
urlexactly like a donor session:Roster.open({ session: url })orRoster.mount(element, { session: url }). The manage flow does not sendflow_started; it sendsflow_completedonly when the donor cancels the gift, withaction: "cancelled".
The donor confirms with a code sent to their phone before anything changes. Today the manage flow does not fill in donor_phone and does not use your Donor Flow appearance: the donor types their phone number. The url is returned once, is single-use and expires after 30 minutes. An enrollment that is not yours (not at one of your churches, or made through another partner at a church you share) gets the same 400 as one that does not exist. Manage sessions do not appear in GET /v1/donor_sessions/{id} or in partner_session.* events; what the donor changes arrives through events like any other change to the gift.
A donor who already gives elsewhere
A donor holds one paycheck gift per payroll account. When a donor who
already has an active gift at another church completes a session for your
church, the hosted flow asks whether to Add a second gift (another
employer or payroll account) or Move my gift here. Moving emits
enrollment.cancel_requested on the previous enrollment, which may belong to
another church or another partner. The new enrollment starts with the value
the donor chooses on that screen, not necessarily the session's value. The
previous partner sees that cancellation request like any other: its payload
does not say why. A session opened by a donor who already gives to your
church does not create a second enrollment: after verification the flow ends,
and the donor can change or cancel in the manage flow. No
partner_session.completed follows, and the session stays opened. That donor
still reaches the last screen, so onComplete runs (and an app gets
roster=completed), but no enrollment event follows for this session.
Reopening the hosted link
The url opens once. The first open spends it, and from then on the flow
keeps its place in that same tab: a reload, or a return from the payroll or
verification window, continues the journey until expires_at or until the
journey completes. The same url opened anywhere else (another tab, another
device, or a new web view in your app) answers 410, and so does any open
after expires_at. Reopening never creates a second session or enrollment;
create a fresh session only when the donor still wants to enroll.
Create a session for each donor and never give one donor's session url to
another person: reopened in the same browser tab before expires_at, it
continues the journey of whoever opened it there.
A session nobody opened becomes expired the first time it is read with
GET /v1/donor_sessions/{id}, or its link is opened, after expires_at. Only
then does partner_session.expired reach your webhook and feed; nothing sends
it at the expiry time on its own. If you re-send unopened links, read the
session after expires_at. A session that was opened and never completed
keeps opened after expires_at, and no event follows: Roster does not know
how the visit ended.
Repeated Connect requests for the same active authorization reuse the existing payroll session. The server derives the replay scope from the authenticated donor, enrollment, authorization and destination; integrations do not generate an additional idempotency header for the hosted flow. A session is renewed only after its confirmed expiry. An uncertain vendor response requires reconciliation before another session can be issued. Restoring the flow reads status without creating a payroll session; the donor explicitly selects Connect when needed. The manage flow presents a new Recurring Transfer Authorization before a donor confirms a changed amount, including a reduction. A new verification code does not replace that confirmation. Reductions take effect immediately after a valid confirmation; an increase may have a waiting period. Cancellation remains a separate action and does not require accepting a changed amount.
A donor can also switch a gift between a percentage and a fixed dollar amount
per paycheck, in either direction, from the same screen. The new authorization
states the new unit. A percentage and a dollar amount cannot be compared, so a
switch is confirmed like an increase: the donor may be asked for a new code,
confirms amounts above 20 percent or above $3,000 per paycheck once more, and
a switch into those amounts has a waiting period. Once it takes effect,
enrollment.updated carries the new mode together with the new value. A gift
split among funds keeps each fund's share across the switch, and every
donation.updated divides the new amount in the same proportion.
For reporting, the enrollment's mode and value describe the allocation already
in effect. A scheduled increase is a pending instruction, not an applied value:
nothing reaches your webhook or the event feed until the engine applies it, and
then enrollment.updated arrives with the new value. The sandbox keeps the
same waiting period as production, so a reduction is the quickest way to prove
enrollment.updated end to end. Reconcile with the enrollment resource after
the change takes effect.
A percentage gift can also become a fixed amount before it starts. Some
payrolls cannot send a percentage of a paycheck online. When the donor's
payroll is one of them, the flow offers a fixed dollar amount per paycheck.
When the donor chooses one, you receive enrollment.updated with
mode: fixed and the new value right away, before the donor accepts the new
authorization for that amount. The gift is not authorized or in effect until
enrollment.activated arrives: a donor can still stop before accepting, and
then nothing is set up. Once enrollment.activated arrives, report the gift
with the values in effect, not the ones you sent when creating the session. A
donor who declines the fixed amount can still set up the percentage through
their payroll office.
If the donor's account is in review, automated account preparation is paused.
The donor flow shows that review state rather than an indefinite preparation
message. Follow the support instructions shown there; a new session, repeated
consent, or a passed identity check does not clear an unresolved review. If
you tell the donor anything about the review, say only that it is in review;
never suggest a reason. In
the sandbox, the identity review of a test number ending in 0190 to 0199 is
the exception: you decide it with POST /sandbox/simulate/kyc (see
Identity check).
If the donor session expires while confirming a gift, the hosted flow requests a new verification code and restores the current state. The donor must explicitly confirm any remaining authorization; the flow does not replay consent automatically.
When an existing donor reopens a partner entry and verifies their identity, the hosted flow uses their current enrollment state to resume. A new entry session does not require repeating initial agreements or identity submission for an existing pending enrollment. A case in review remains in review.
When a donor changes a fixed gift in the hosted flow, the proposed dollar amount must resolve to whole cents (for example, 12.50 is valid; 0.001 is not). The donor reviews the updated terms and explicitly accepts them before the change can proceed.
Donor Flow settings and preview
The Donor Flow tab combines appearance, the declaration of data you intend to collect, and an interactive visual demo. The demo always uses fictional data. It does not send verification codes, capture documents, connect payroll, accept an authorization or create a donation. Route percentages are illustrative estimates, not measured conversion rates.
Use GET /v1/flow-settings and PUT /v1/flow-settings to read and save the same settings as the portal. Your API key authenticates these operations. Every read returns a version; send it back as expected_version when you save, so a save never undoes someone else's (see Concurrent saves). Without church_id, a PUT replaces the partner default, so read it first and send every property you want to keep. With church_id, a PUT stores only what you send for that church, as described below. The response returns the saved settings in settings, in exactly the shape the PUT accepts (button_label, brand_color, customization…), so you can read the partner default, change a field and send it back. With church_id, the read returns what applies to that church, partner values included, in settings, and what the church itself stores in church_settings: to change the church, apply your change to church_settings and send it back whole, as described below. The older skin object carries the same values with camelCase names; it is deprecated, so read settings.
A save of the partner default, with the version read before it:
{
"expected_version": 3,
"button_label": "Give",
"brand_color": "#4f3fd8",
"customization": {
"css": { "button_color": "#4f3fd8", "modal_width": "400px", "border_radius": "8px", "font_family": "Inter" },
"collected_fields": ["donor_phone", "donor.date_of_birth"],
"customize_churches": true
}
}
The church ID and gift mode/value are always required when creating a donor session. collected_fields declares optional information your integration intends to send; it contains field names, never donor values. Individual sessions may omit optional fields. In the preview, a selected phone number or date of birth appears with a fictional value; names are not shown on a demo screen.
Omit church_id to save defaults for the partner. button_label is required there; a save without brand_color removes the saved color, and the donor flow uses its standard look. To customize a church, first save the partner default with customize_churches: true; otherwise a church write returns 409. A church write stores only what you send: omitted button_label, brand_color, collected_fields, CSS properties and other appearance fields come from the partner default, so later partner changes still reach that church. customization is required in every write; for a church it can be {}. Start from church_settings in the read, apply your change and send that object whole, not the settings you read for the church: those already include the partner values, and manage_url, which a church write refuses with 400. Unconfigured churches inherit the partner appearance. Settings are isolated by partner and environment. They are copied into new donor sessions; existing sessions keep their original appearance.
The Donor Flow page edits every property in customization.css, the collected fields and per-church customization; its button color is also saved as brand_color. button_label, display_name, support_contact, logo_url, cta_label and manage_url are set with PUT /v1/flow-settings, and saving from the page keeps their saved values. A save from the page also raises version, so an API save with an older expected_version gets 409 instead of undoing it. Only supported visual properties are accepted—no scripts, external CSS resources or arbitrary selectors. These properties affect Roster screens. External verification and payroll screens keep their own appearance; the preview identifies their demonstrations separately.
Concurrent saves
Two people can change the same settings: two developers through the API, or a developer and someone on the Donor Flow page of the portal. Neither overwrites the other blindly.
- Every
GET /v1/flow-settingsreturnsversion:0before the first save, then one more on every save, from the API or the portal. Saved settings never read0: settings saved before versions were counted read1. - Send the
versionyou read asexpected_versionin the PUT. A PUT without it returns400. - A successful PUT returns the new
version. Keep it for your next save instead of reading again. - A successful PUT also returns
removed: the properties the save took away, named by their path in the request body (display_name,customization.css.padding), and empty when it removed nothing. If it lists a property you meant to keep, save again with it. - If someone saved after your read, nothing is saved and the PUT returns
409(conflict). The body carries thesettingsand theversionas they are now, in the same shape as the GET. Apply your change to thosesettingsand send them again with thatversion. Withchurch_id, apply your change tochurch_settingsand send that object whole with itsversion; never resendsettings, which includes the partner default.
Do not resend your old body with the new version: that undoes the other person's change, which is what the check prevents. If your change and theirs touch the same property, decide which value wins before you resend.
With church_id, version counts the saves of that church's own settings, and the expected_version you send is compared with it. A change to your partner default does not change a church's version, so a church save is not refused because the default changed.