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 20,000 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, with a checkbox, before the gift is created. Above $4,500 per paycheck, the gift is created and its transfers wait until the donor confirms the amount from an email. 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. To show the confirmed gift on your own page at that moment, read the session and the enrollment, as described in Show the gift when the flow completes. 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
The payroll provider's window opens inside the flow, on the payroll-signin
screen, and closes by itself once the payroll is connected. Closing it does
not activate an enrollment. If the donor closes it before connecting, the
same screen offers to open it again (payroll-signin). If it closes after the
donor signed in but before the gift was saved as authorized, the flow shows
allocation-adjust, and the donor picks up where they left off without
signing in again. 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; only the donor's own action on the
payroll screens does. 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, authorizes the giving account (agree-account, required) and accepts the agreements. email appears when verification does not supply one, which is always the case in the sandbox. If the email's domain does not receive mail, Continue brings the donor back here with the email open to check, before identity verification; this also happens with test numbers. 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-account, agree-bundle, agree-amber, pii-given, pii-family, pii-line1, pii-city, pii-state, pii-zip |
another-church |
The donor already gives to another church: add a second gift or move the current one here. Each new gift opens its own giving account, so both choices need agree-account ticked |
gift-add, gift-move |
agree-account |
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 20,000, in whole dollars, and must be typed. pct-continue continues in either unit. After gift-add or gift-move, above 20 percent or above $3,000 per paycheck, the first pct-continue shows agree-amber, and the next one needs it ticked |
amount-mode, pct-continue, go-manage |
amount, agree-amber |
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 |
payroll-signin |
The payroll connection, right after confirm. The payroll provider's window opens inside this screen by itself, to find the donor's payroll and sign in to it; while it is open, payroll-signin is hidden. When the window closes before the connection, payroll-signin opens it again. In the sandbox the window runs in the payroll provider's test mode (see Test donors). go-hr-form sets the gift up with the payroll office instead |
payroll-signin, go-hr-form |
— |
payroll-confirm |
The payroll is connected: the donor checks the gift and the payroll it comes from, and saves it there | payroll-install |
— |
payroll-installing |
The gift is being saved to the payroll. No button: wait for the next screen | — | — |
payroll-confirming |
The payroll took the gift and Roster waits for the confirmation. It moves on by itself. After a few minutes it says the confirmation can take longer and shows close-flow, which only closes the window |
close-flow |
— |
fixed-instead |
The donor's payroll does not take a percentage: the flow offers a dollar amount per paycheck instead. The donor types it in fixed-amount (from $1 to $20,000, in whole cents). Above $3,000, the first fixed-continue shows agree-amber, and the next one needs it ticked. fixed-continue returns to confirm, where the donor accepts a new authorization for that amount before the payroll window opens again; fixed-not-now goes to hr-form with the percentage |
fixed-continue, fixed-not-now |
fixed-amount, agree-amber |
allocation-adjust |
The payroll window closed without saving the gift as authorized; data-reason says what happened. adjust-fix reopens the window to correct it, adjust-keep keeps a lower amount the payroll saved, adjust-resume reopens the window where the donor left off, adjust-no-percentage goes to fixed-instead when the payroll offered no percentage, and go-hr-form goes to the payroll office. None of them asks the donor to sign in again |
adjust-fix, adjust-keep, adjust-resume, adjust-no-percentage, go-hr-form |
— |
connect |
The payroll connection as a single screen, used instead of payroll-signin where the payroll window cannot open inside the flow. connect-open opens the payroll window; go-hr-form goes to the payroll office |
connect-open, go-hr-form |
— |
success |
The gift is set up. The donor can see it while Roster still waits for the payroll's confirmation; flow_completed is sent once the gift is active |
close-flow, go-manage |
— |
in-review |
The donor's account is in review. Without buttons, this screen does not check again by itself. When identity verification asks for a photo of an ID, the screen carries data-docv: intro explains the photo and offers docv-here (take it on this device, on a phone) or docv-text (text a link to the donor's phone, on a computer), and docv-later; here shows the camera inside the flow, with docv-back; phone waits for the photo taken on the phone, with docv-text to send the link again and docv-back. It moves on by itself once the photo is checked. Where the photo screens are not used, the same request is one screen without data-docv: docv-open opens the photo, and docv-back returns while it is being checked |
docv-here, docv-text, docv-open, docv-later, docv-back |
— |
docv-deferred |
The donor chose to add the ID later, or left the photo of the ID without taking it. The place is kept for 14 days | — | — |
declined |
Closed: the gift could not be set up | — | — |
hr-form |
After go-hr-form or fixed-not-now, or when the payroll cannot take the gift online: 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 20,000, 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 at the bottom of the screen, above the screen's
own buttons. It appears after reconnect, finish-disconnect or
finish-disconnect-previous when the payroll provider opens in a separate
browser window instead of inside the flow: once the donor finishes there,
Check connection status reads the result.
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. -
On the payroll screens, wait for the next screen.
payroll-installingandpayroll-confirminghave no button to press and move on by themselves; the payroll provider's window insidepayroll-signinis another site's frame, so reach it through your test tool's frame list, as with the flow's own iframe. A test that cannot sign in to a payroll can activate the enrollment withPOST /sandbox/simulate/payroll-connectedonce the donor reachespayroll-signin(see 4 Activate the enrollment). -
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.
Show the gift when the flow completes
To show "your recurring gift is set up" on your page at the moment of
flow_completed, without waiting for a webhook, make two reads from your
backend:
-
GET /v1/donor_sessions/{id}:statusiscompletedand it carries theenrollment_id. -
GET /v1/enrollments/{id}?church_id=...with that ID:modeandvalueare the gift the donor confirmed. If the donor switched from a percentage to a dollar amount in the flow, they already carry the dollar amount: that change is recorded when the donor accepts the new authorization, before the last screen.
Show the amount from that read; do not show the one you sent when creating the
session. Treat the enrollment as active only when its status reads active
or enrollment.activated arrives. A donor who already gives to your church
also reaches the last screen, but the session stays opened with no
enrollment_id (see A donor who already gives
elsewhere): show a neutral thank-you
in that case.
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 requests to open the payroll window 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 response from the payroll provider requires reconciliation before
another session can be issued. Restoring the flow reads status without
creating a payroll session; the payroll window opens again only from the
payroll screens (payroll-signin, allocation-adjust).
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.
When the payroll does not take a percentage
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, the flow presents a new authorization for that
dollar amount. Only after the donor accepts it do you receive
enrollment.updated with mode: fixed and the new value in dollars. A
donor who stops before accepting sends no event, and the enrollment keeps the
session's percentage. The gift is not in effect until enrollment.activated
arrives. 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.
Review, expiry and returning donors
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. The exception is the photo of an ID: a donor who chose to add it later, or who left without taking it, comes back the same way within 14 days, checks the details on review again with agree-account ticked, and then takes the photo.
A donor who leaves before finishing, for example to find their ID, can come
back to the same giving page and enter the amount again. If the gift has not
started yet and the donor enters a different amount, the pending gift takes the
new amount and you receive enrollment.updated with the new mode and
value. The same rule applies: report the values in effect once
enrollment.activated arrives.
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 walks the same screens as the hosted flow, including the payroll screens around the payroll provider's window, the fixed amount offered when a payroll does not take a percentage, and the payroll office path. 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, including the payroll and ID-photo screens around the providers' windows. The payroll provider's window and the ID camera 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.