Skip to main content

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

  1. Your backend calls Swirepay's POST /v3/checkout-session with your secret key and the session config (scope: "transfer", toContactGid, currency, acceptedDomain, and optionally senderContactGid/transferType — see Checkout session). This must happen server-side; the secret key must never reach the browser.
  2. Swirepay's backend returns a secure token (entity.encryption) plus a transferSessionGid identifying this specific transfer.
  3. 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.).
  4. Your frontend mounts <swirepay-transfer-center>, sets secureToken (and themeJsonString) as JS properties, and registers successCallback/errorCallback.
  5. 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 (senderContactGid was set at mint time), the shopper additionally confirms a one-time code before the transfer completes — see §10.
  6. successCallback fires 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 the transferSessionGid you recorded in step 2 (the webhook payload's top-level gid) rather than relying on successCallback alone 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 attributeTypeRequiredDescription
secureTokensecure-tokenstringYesThe compact token (entity.encryption) from Checkout session. May be attached after initial mount.
themeJsonStringthemeJSON stringNoVisual theming — see §11 below.
successCallback(JS property only)(payload?: TransferCenterSuccessPayload) => voidNoCalled once the transfer session resolves to success. Strongly recommended to set this up. See §7.
errorCallback(JS property only)(payload: ErrorPayload) => voidNoCalled 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

  • senderContactGid present 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).
  • transferType present 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 returned message so 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 theme JSON, 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:

FieldInputValidation
FromSearchable account pickerMust select an account.
ToSearchable account picker, showing only the fixed recipient's own accountsMust select an account, and it must differ from the "From" account ("From and to accounts must be different").
AmountText input, digits + up to 2 decimal placesRequired; 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 DateNative date picker, minimum today (in the account's own timezone, not the shopper's browser timezone)Required; cannot be in the past.
RemarksFree-text fieldRequired despite showing no visible asterisk in the UI — letters, numbers, and spaces only.
Transfer modeA 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 toContactGid when 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 swirepayOtp data) 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 call errorCallback.
  • If confirmation instead resolves to status: "FAILED", that is a fatal error — errorCallback fires with fatal: true and the widget unmounts (see §6).
  • Any other status (including SUCCEEDED) closes the modal and fires successCallback.

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.

KeyTypeAffects
componentBackgroundColorcolorOverall component background
inputBackgroundColorcolorInput/select field background
inputBorderRadiuslength (px/rem/em/%)Input/select corner radius
infoBackgroundColorcolorInfo banner background
infoBorderColorcolorInfo banner border
buttonBackgroundColorcolorPrimary button background
buttonBorderRadiuslengthPrimary button corner radius
buttonTextColorcolorPrimary button text color
spacingXs / spacingSm / spacingMd / spacingLglengthInternal spacing scale
fontFamilynon-empty stringFont family
fontColorcolorBody text color
fontColorErrorcolorValidation-error text color
fontSizeSm / fontSizeMd / fontSizeLglengthFont size scale
fontWeightRegular / fontWeightMedium / fontWeightBoldinteger 100–900Font 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

  1. Log in to the Swirepay dashboard.
  2. In the left sidebar, click Develop.
  3. Under Develop, click Webhook.
  4. On the Webhook page, click Add Webhook (top right).
  5. In the modal, set the server endpoint that will receive the webhook payload, a notification email, and a secret value.
  6. 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-signature header. 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-event header names the event, generally following the pattern TRANSFER_<STATUS> (e.g. TRANSFER_PROCESSING for a transfer session whose status is PROCESSING).

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

FieldMeaning
gidThe transfer session's gid — match this against the transferSessionGid you recorded when minting the checkout session (Checkout session).
statusThe transfer session's current status — see the status list below.
errorCode / errorDescriptionPopulated when the transfer does not complete successfully (e.g. status is DECLINED or CANCELLED); null otherwise.
amount / amountTransferred / amountRefunded / amountPending / amountDisputedAll in minor currency units (cents for USD).
transferTypeCodeThe rail originally requested (ACH, RAPID_ACH, or RTP).
smartRoutingNoteSet 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.
contactThe fixed recipient ("to") contact — matches the toContactGid you set when minting the session.
fundingSourceThe recipient's account the money moved into.
payerFundingSourceThe sender's account the money moved out of.
scheduleDate / processedAtWhen 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/errorDescription are 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 errorCallback never fires for it.
  • Trying to change toContactGid, senderContactGid, or transferType after minting — all three are fixed for the life of a checkout session. To change any of them, mint a brand-new session and reassign secureToken to 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 successCallback alone is authoritative — it's a best-effort client-side signal; configure a dashboard webhook and reconcile final status against your own records using the transferSessionGid you 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 smartRoutingNote in §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 RTP in the UItransferType: "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.