Transfer Center: setup and integration flow
swirepay-transfer-center is a single custom element that lets a merchant move money from an
account (the merchant's own funding source, or a specific sender contact's funding source) to a
fixed recipient contact determined entirely by the checkout session you mint — all inside a
PCI-isolated environment.
This page covers everything except minting the session: the end-to-end flow, mounting the element, every prop it accepts, the Send Money form, funding source selection, sender-scoped OTP confirmation, theming, testing, and webhook integration. For minting the checkout session, see Checkout session.
1. The end-to-end integration flow
- Your backend calls Swirepay's
POST /v3/checkout-sessionwith your secret key and the session config (scope: "transfer",toContactGid,currency,acceptedDomain, and optionallysenderContactGid/transferType— see Checkout session). This must happen server-side; the secret key must never reach the browser. - Swirepay's backend returns a secure token (
entity.encryption) plus atransferSessionGididentifying this specific transfer. - Your backend records that
transferSessionGid(you'll want it to reconcile the transfer's status in your own system later) and hands the secure token to your frontend, along with whatever prop values you want to pre-fill (theme, etc.). - Your frontend mounts
<swirepay-transfer-center>, setssecureToken(andthemeJsonString) as JS properties, and registerssuccessCallback/errorCallback. - The shopper picks a "from" account and (implicitly) the fixed "to" recipient, enters an amount,
a schedule date, remarks, and a transfer mode, reviews the summary, and submits — all inside the
widget's PCI-isolated environment. If the session is sender-scoped (
senderContactGidwas set at mint time), the shopper additionally confirms a one-time code before the transfer completes — see §10. successCallbackfires once the transfer session resolves to a successful status. This is a best-effort, client-side signal only — it can be missed if the shopper's tab closes, the network drops, or the browser is killed right after submission. Also configure a webhook in the Swirepay merchant dashboard for this account — it's the authoritative, server-to-server source of truth for final transfer status. Reconcile it against your own records using thetransferSessionGidyou recorded in step 2 (the webhook payload's top-levelgid) rather than relying onsuccessCallbackalone for anything that must be authoritative — see §13.
2. Live vs. Test mode
Your Swirepay account has a Live / Test toggle in the merchant dashboard, each with its own secret key. The client-side integration is identical either way — same endpoint, same element, same props. Only the secret key your backend uses to mint the checkout session changes.
Using the test variant of the secret key puts the session in test mode. Both keys are found under Developer in the dashboard's sidebar.
Live mode creates real, chargeable transfers against real funding source accounts.
3. Load the SDK and mount the element
Install the package from npm:
npm install @swirepay-developer/swirepay-frontend-payment-sdk@latest
// Registers <swirepay-transfer-center> as a custom element, as a side effect of the import.
import '@swirepay-developer/swirepay-frontend-payment-sdk';
Or, if you don't use a bundler, load it directly from a CDN with a plain <script> tag (pin an
exact version rather than a floating tag):
<!doctype html>
<html>
<body>
<swirepay-transfer-center id="transfer-center"></swirepay-transfer-center>
<script src="https://unpkg.com/@swirepay-developer/swirepay-frontend-payment-sdk@latest/dist/prod/swirepay-sdk.iife.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
customElements.whenDefined('swirepay-transfer-center').then(async () => {
const el = document.getElementById('transfer-center');
// Get the base64 encryption token from your backend
const res = await fetch('/api/swirepay/transfer-token', {
method: 'GET',
});
const { secureToken } = await res.json();
el.secureToken = secureToken;
// handle success state
el.successCallback = (payload) => console.log('transfer succeeded', payload);
// handle both fatal and non-fatal failure cases
el.errorCallback = (payload) => console.error('transfer failed', payload);
});
});
</script>
</body>
</html>
Until secureToken is set, the widget shows a "Please wait…" placeholder — it is safe to mount the
element before the token is ready and attach secureToken later.
4. Props reference
| Prop (JS property) | HTML attribute | Type | Required | Description |
|---|---|---|---|---|
secureToken | secure-token | string | Yes | The compact token (entity.encryption) from Checkout session. May be attached after initial mount. |
themeJsonString | theme | JSON string | No | Visual theming — see §11 below. |
successCallback | (JS property only) | (payload?: TransferCenterSuccessPayload) => void | No | Called once the transfer session resolves to success. Strongly recommended to set this up. See §7. |
errorCallback | (JS property only) | (payload: ErrorPayload) => void | No | Called on a fatal setup error or a recoverable transfer-attempt failure. Strongly recommended to set this up. See §6. |
Functions can't be passed as HTML attributes (always strings), so successCallback/errorCallback
are only settable as JS properties, e.g. document.querySelector('swirepay-transfer-center').successCallback = fn;.
5. Prop interactions
senderContactGidpresent at mint time → the "from" side shows that contact's funding sources instead of the merchant's own, and OTP confirmation is required before the transfer completes (see §10).transferTypepresent at mint time → only those transfer-mode chips render (see §8).
6. Error payload
errorCallback receives an object shaped like:
type ErrorPayload = {
message: string;
cause?: unknown;
fatal: boolean;
};
fatal: true means a fatal setup error — the widget UI needs to be unmounted, since there's
nothing left for the shopper to retry from; re-mount the element with a fresh secure (encryption)
token to try again. fatal: false means a recoverable, in-widget failure — the form stays usable.
Fatal (fatal: true — widget should be unmounted)
- A missing, malformed, or wrong-scope
secure-token(scope must be exactly"transfer"). - Failure to load required scripts on mount.
- Submitting the transfer resolves to
status: "FAILED"on the transfer session. - Confirming the OTP code resolves to
status: "FAILED"on the transfer session.
Recoverable (fatal: false — form stays usable)
- Any other thrown error while submitting the transfer (network failure, unexpected server error, etc.) — excluding the silent 424 case below.
- Any other thrown error while confirming or resending the OTP code — excluding the silent 412 case below.
- These still need to be handled via
errorCallback— show the shopper the returnedmessageso they know what went wrong and that they can try again.
When errorCallback does NOT fire (silent failures, inline-only)
- A 424 response while submitting the transfer — shown inline as "Please re-verify and link funding source using Plaid," but the widget has no actual embedded flow to do that re-linking — see the pitfall in §14.
- The transfer session resolving to
status: "REQUIRES_CONFIRMATION"again after OTP entry (i.e. a wrong or expired code) — the OTP modal stays open, shows an inline message, clears the digit boxes, and lets the shopper retry. - Resending the OTP code within its 30-second cooldown (HTTP 412) — shown inline under the resend
row, not sent to
errorCallback. - Malformed
themeJSON, or JSON that doesn't match the theme schema — logged to the console only, default theming is used. - Ordinary client-side field validation (empty required field, amount out of range, invalid date,
etc.) — shown inline under the field, never sent to
errorCallback.
7. Success payload
successCallback receives a small, fixed summary of the completed transfer session — not the
full raw record (which carries far more detail, e.g. the recipient's full contact/address, funding
source internals, etc.):
type TransferCenterSuccessPayload = {
gid: string;
scheduleDate: string | null;
status: string;
amount: number;
sessionType: string;
};
gid is the transfer session's own gid — this should match the transferSessionGid you recorded
from the mint response (see
Checkout session); use it
to reconcile against your own records — but the webhook payload (see
§13) should always be treated as the authoritative answer, not this
callback. amount is in minor currency units (cents for USD), matching what the shopper entered.
scheduleDate reflects when the transfer is scheduled to process (see
§8's date field), not necessarily "now."
8. The Send Money form
The shopper fills in six fields, all required, in this order:
| Field | Input | Validation |
|---|---|---|
| From | Searchable account picker | Must select an account. |
| To | Searchable account picker, showing only the fixed recipient's own accounts | Must select an account, and it must differ from the "From" account ("From and to accounts must be different"). |
| Amount | Text input, digits + up to 2 decimal places | Required; must parse as a valid amount; must fall within the account's configured min/max (falls back to $1.00–$100,000.00 if the account has no configured limits — see below). |
| Transfer Date | Native date picker, minimum today (in the account's own timezone, not the shopper's browser timezone) | Required; cannot be in the past. |
| Remarks | Free-text field | Required despite showing no visible asterisk in the UI — letters, numbers, and spaces only. |
| Transfer mode | A row of chips (only those present in transferType from the mint call render — all three show if transferType was omitted) | Must select one of ACH ("ACH"), RAPID_ACH ("Rapid ACH"), RTP ("Instant Payment"). |
Default transfer mode: which chip (if any) is preselected is based on the default transfer type configured in Transfer Settings in the Swirepay dashboard for this account.
After filling in every field, the shopper reaches a Review screen (a summary of both accounts, amount, date, mode, and remarks) before the final Submit — clicking "Back" returns to the editable form without losing any entered values.
9. Funding source selection
- From: normally the merchant's own funding sources. If the session was minted with
senderContactGid, this instead shows that specific contact's own funding-source accounts. - To: always the fixed recipient contact's own funding-source accounts — the one identified by
toContactGidwhen you minted the session. The shopper cannot search for or select a different recipient; they can only choose among that recipient's own linked accounts.
Each account row shows the bank name, last four digits, and badges for Individual/Business, Verified, RTP-enabled, and Virtual-account status, drawn from the funding source's own data — nothing here is configurable via a widget prop.
Pitfall: if submitting returns HTTP 424, the widget shows "Please re-verify and link funding source using Plaid" as an inline message — but there is currently no embedded Plaid-relink flow in this widget to act on that message. Treat a 424 as a signal to direct the shopper (or handle via your own backend tooling) outside the widget, not as something they can resolve in place — the underlying Plaid-linked funding source needs to be checked and potentially re-verified.
10. Sender-scoped OTP confirmation
When the checkout session was minted with senderContactGid, submitting the transfer form doesn't
complete the transfer immediately — the backend always sends a one-time code as part of that same
submission, and the widget opens a 6-digit OTP entry modal for the shopper to confirm it.
- OTP confirmation for sender-scoped transfers must be enabled for the account — the Push OTP For Contact Transfer toggle in Transfer Settings in the Swirepay dashboard.
- The modal shows where the code was sent (email and/or phone, from the transfer session's own
swirepayOtpdata) and a 30-second resend cooldown. - Resending within the cooldown (HTTP 412) shows an inline message under the resend row — this
does not call
errorCallback(see §6). - Entering a wrong or expired code resolves the transfer session back to
status: "REQUIRES_CONFIRMATION"— the modal clears the digit boxes, shows an inline error, and stays open for another attempt. This also does not callerrorCallback. - If confirmation instead resolves to
status: "FAILED", that is a fatal error —errorCallbackfires withfatal: trueand the widget unmounts (see §6). - Any other status (including
SUCCEEDED) closes the modal and firessuccessCallback.
A session minted without senderContactGid never shows this modal — submitting the form
completes (or fatally fails) the transfer directly.
11. Theming
theme/themeJsonString accepts a flat JSON object — every key is optional; anything you omit
falls back to the SDK's defaults, and if the JSON itself is malformed or fails validation the
entire theme is discarded (fails closed to defaults) rather than partially applied.
| Key | Type | Affects |
|---|---|---|
componentBackgroundColor | color | Overall component background |
inputBackgroundColor | color | Input/select field background |
inputBorderRadius | length (px/rem/em/%) | Input/select corner radius |
infoBackgroundColor | color | Info banner background |
infoBorderColor | color | Info banner border |
buttonBackgroundColor | color | Primary button background |
buttonBorderRadius | length | Primary button corner radius |
buttonTextColor | color | Primary button text color |
spacingXs / spacingSm / spacingMd / spacingLg | length | Internal spacing scale |
fontFamily | non-empty string | Font family |
fontColor | color | Body text color |
fontColorError | color | Validation-error text color |
fontSizeSm / fontSizeMd / fontSizeLg | length | Font size scale |
fontWeightRegular / fontWeightMedium / fontWeightBold | integer 100–900 | Font weight scale |
"color" accepts hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba(), or hsl()/hsla().
Example:
{
"buttonBackgroundColor": "#3b5bfd",
"buttonTextColor": "#ffffff",
"inputBorderRadius": "8px",
"fontFamily": "Inter, sans-serif"
}
12. Testing your integration
Test everything end-to-end in Test mode first (see §2): mint a session
with your test secret key, mount the element exactly as shown in
§3, and walk through a full submission — both without
senderContactGid (direct completion) and with it (the OTP confirmation path in
§10) if your integration uses sender-scoped transfers.
Only switch to a live secret key once the full flow behaves the way you expect.
Test transactions and data are kept separate from live ones, and are only visible in the dashboard when its Test toggle (at the bottom of the sidebar) is turned on.
13. Webhook Integration
Webhooks are the authoritative, server-to-server source of truth for a transfer's final status —
set one up rather than relying on successCallback/errorCallback alone (see
§1).
Setup
- Log in to the Swirepay dashboard.
- In the left sidebar, click Develop.
- Under Develop, click Webhook.
- On the Webhook page, click Add Webhook (top right).
- In the modal, set the server endpoint that will receive the webhook payload, a notification email, and a secret value.
- Check the Transfer checkbox and select all statuses, so you receive a webhook call for every transfer-status change.
Verifying and identifying a webhook call
- The secret you set in step 5 is used to compute an HMAC-SHA256 hash of the payload — the
resulting hash is sent in the
x-swirepay-signatureheader. To validate an incoming call, your server should compute the HMAC-SHA256 hash of the received payload using the same secret and compare it against that header's value; reject the call if they don't match. - The
x-swirepay-eventheader names the event, generally following the patternTRANSFER_<STATUS>(e.g.TRANSFER_PROCESSINGfor a transfer session whosestatusisPROCESSING).
Payload
The webhook body is the transfer session itself — the same shape returned internally throughout
the widget, not a separate reduced format. Below is a real example (status PROCESSING, an ACH
transfer that was rerouted from RTP), trimmed of its repeated nested contact/issuerBank data —
the full payload repeats those same shapes inside fundingSource, payerFundingSource, and the
rail-specific transfer object (achTransfer here):
{
"gid": "transfersession-1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6",
"createdAt": "2026-09-09T09:47:32.109314",
"updatedAt": "2026-09-09T09:47:32.109316",
"amount": 200,
"amountRefunded": 0,
"amountDisputed": 0,
"amountPending": 200,
"amountTransferred": 0,
"description": "Fast food chain",
"otpCode": null,
"transferDate": null,
"errorCode": null,
"errorDescription": null,
"status": "PROCESSING",
"uniqueReferenceNumber": "620f8cdc4c044c769ad0c2015e040415",
"transferTypeCode": "ACH",
"currency": { "id": 1, "name": "USD", "prefix": "$", "toFixed": 2, "countryAlpha2": "US" },
"contact": {
"gid": "contact-5bf93de6e95a4933986e11297e09f6a1",
"name": "Jane Doe",
"email": "",
"phoneNumber": "+15555550100",
"contactType": "CUSTOMER",
"status": "VERIFIED"
// ...address/tax/other contact fields
},
"fundingSource": {
"gid": "fundingsource-a8c36c7a298a432e8c25ab90938fcf31",
"isVerified": true,
"status": "VERIFIED",
"contact": { /* the recipient's own contact record — same gid as "contact" above */ },
"issuerBank": {
"gid": "issuerbank-3f8b2c91d4e64a7b9c0d1e2f3a4b5c6d",
"bankName": "Bank of America",
"accountType": "CHECKING",
"lastFour": "0000",
"isVerified": true,
"rtpsupported": true
// ...other issuerBank fields
}
},
"payerFundingSource": {
"gid": "fundingsource-34a96f8ebff644ccb822e9e23e39fa06",
"isVerified": true,
"status": "VERIFIED",
"contact": { "gid": "contact-2378de7dcdd4483fb986acbf551aa760", "name": "John Doe", "contactType": "SELF" /* ... */ },
"issuerBank": { /* same shape as fundingSource.issuerBank above */ }
},
"feeAmount": 1,
"feeTax": 0,
"achTransfer": {
"gid": "achtransfer-252663db9c834948b47af2b261407a2c",
"status": "PROCESSING",
"amount": 200,
"type": "ACH"
// ...its own fundingSource (same shape as the top-level one above), fee/tax/net, etc.
},
"rtpTransfer": null,
"impsTransfer": null,
"neftTransfer": null,
"rtgsTransfer": null,
"upiTransfer": null,
"transferMethod": "AUTOMATIC",
"scheduleDate": "2026-09-09T09:47:17",
"total": 200,
"smartRoutingNote": "Rerouted from RTP to ACH",
"processedAt": "2026-09-09T09:47:34.645237",
"approvalStatus": "APPROVED",
"sessionType": "SEND_MONEY",
"swirepayOtp": null,
"deleted": false
}
Key fields for reconciliation
| Field | Meaning |
|---|---|
gid | The transfer session's gid — match this against the transferSessionGid you recorded when minting the checkout session (Checkout session). |
status | The transfer session's current status — see the status list below. |
errorCode / errorDescription | Populated when the transfer does not complete successfully (e.g. status is DECLINED or CANCELLED); null otherwise. |
amount / amountTransferred / amountRefunded / amountPending / amountDisputed | All in minor currency units (cents for USD). |
transferTypeCode | The rail originally requested (ACH, RAPID_ACH, or RTP). |
smartRoutingNote | Set when the transfer was automatically rerouted to a different rail than requested (e.g. "Rerouted from RTP to ACH", as in the example above) — worth surfacing or logging, since the rail that actually ran the transfer can differ from transferTypeCode. |
contact | The fixed recipient ("to") contact — matches the toContactGid you set when minting the session. |
fundingSource | The recipient's account the money moved into. |
payerFundingSource | The sender's account the money moved out of. |
scheduleDate / processedAt | When the transfer was scheduled to run vs. when it actually processed. |
Status values
The x-swirepay-event header (and the payload's own status field) uses one of these five
values:
PROCESSING— actively being processed.SCHEDULED— scheduled for a future date, not yet started.SUCCEEDED— completed successfully.CANCELLED— cancelled before completing.DECLINED— declined;errorCode/errorDescriptionare populated.
14. Common pitfalls
- Minting the session token in browser JS — only ever do this server-side in production (the dev-preview page in this repo does it client-side purely for local testing).
- Treating a 424 ("re-verify and link funding source using Plaid") as something the shopper can
fix inside the widget — there's currently no embedded relink flow for it; it's an inline
message only, and
errorCallbacknever fires for it. - Trying to change
toContactGid,senderContactGid, ortransferTypeafter minting — all three are fixed for the life of a checkout session. To change any of them, mint a brand-new session and reassignsecureTokento the new token. - Assuming Remarks is optional because it has no visible asterisk — it's required, and only accepts letters, numbers, and spaces.
- Assuming the transfer date is validated against the user's browser timezone — it's validated against the account's configured timezone instead, which can differ from what the shopper's device reports as "today."
- Assuming
successCallbackalone is authoritative — it's a best-effort client-side signal; configure a dashboard webhook and reconcile final status against your own records using thetransferSessionGidyou recorded at mint time (see §13). - Not checking Transfer Settings in the dashboard when the widget doesn't behave as expected —
account-level settings there directly affect the SDK's behavior: whether OTP confirmation is
required for sender-scoped transfers (see §10), the
min/max transfer amount enforced (see §8), smart routing behavior
(which can silently reroute a transfer to a different rail than requested — see
smartRoutingNotein §13), and more. Unexpected widget behavior is often a Transfer Settings configuration question, not a bug. - Assuming transfers will work as soon as the widget is integrated — the account's issuer rates and issuer settings must be configured and set up for the account before transfers can actually be processed. Contact Swirepay to get this set up for your account.
- Not recognizing
RTPin the UI —transferType: "RTP"renders as "Instant Payment" in the transfer-mode chips (see §8), not as "RTP" — don't expect the raw code to appear as the shopper-facing label.