Unified Enrollment SDK

What you get

One embedded flow for every way a card gets enrolled. The Unified Enrollment SDK drops a single script on your page and handles all three Astrada enrollment rails behind one integration:

  • Card number. The cardholder types their card and 3DS verification activates it (Visa, Mastercard).
  • Bank connection. The cardholder connects the bank behind their card through a hosted bank-login flow, and completion enrolls each credit account as a card (American Express).
  • Network bulk feed. A whole card program is registered from network data (Visa, Mastercard, and eligible American Express corporate programs).

The SDK reads the card's first digits and routes to the right rail automatically, so you write one integration and cover every network. Everything downstream is shared: the same cardsubscription.created webhook, durable cardId, and transaction stream. See Unified Card & Bank Feeds.

📘

One result, however the card was enrolled

A single session can link a bank, enroll a card, and register a bulk feed before it closes. The SDK streams each action as it finishes (onEvent) and hands you one consolidated result at the end (onSuccess): a map of every card, bank account, and bank link created, keyed by id.

Try it

Astrada hosts the SDK as a JavaScript bundle, so the runtime needs no npm install. Optional TypeScript types are available as a separate package; see Using it in React. Add the script and call open() for a cardholder's subaccount:

<script
  type="text/javascript"
  src="https://sdk.astrada.co/unified/v1/unifiedEnrollmentSdk.js"
  data-id="unified-enrollment-sdk"
></script>
<script>
  UnifiedEnrollmentSdk.open({
    subaccountId: "<subaccountId>",
    companyName: "Your company",
    getAccessToken: () => fetch("/your-backend/astrada-token").then((r) => r.text()),
    onEvent: (event) => console.log(event),
    onSuccess: (result) => { /* the whole session, once */ },
    onError: (err) => { /* show an error */ },
    onCancel: () => { /* nothing was enrolled */ },
  });
</script>

Card data renders inside an Astrada-origin iframe and never touches your page. By default the SDK opens as a full-screen modal; pass container to mount it inline instead (see Configure the session).

🚧

Tokens come from your backend

Fetch the access token from your own backend inside getAccessToken, and never ship a client secret to the browser. The callback must resolve within getAccessTokenTimeoutMs (default 5000 ms).

Using it in React (TypeScript)

The same hosted script works in React. Load it once, and let Astrada's published types cover UnifiedEnrollmentSdk so you don't hand-write the declarations.

The unified SDK's types live under the /unified entry point of @astrada.co/card-enrollment-sdk. The package contains type declarations only, with no runtime code, so install it as a dev dependency. Importing it also declares the UnifiedEnrollmentSdk global:

npm install --save-dev @astrada.co/card-enrollment-sdk
import type {
  UnifiedEnrollmentSdkConfig,
  SdkEvent,          // the onEvent union: card-enrolled | bank-linked | feed-created | error | closed | cancelled
  EnrollmentSuccess, // the consolidated onSuccess payload (cards / bankAccounts / bankLinks / bulkFeeds / summary)
} from "@astrada.co/card-enrollment-sdk/unified";

These types are generated from the SDK source, so they match the events and payloads documented below.

Then load the script, either by keeping the <script> tag in your index.html or by injecting it on demand from a hook:

import { useEffect, useState } from "react";

const SDK_SRC = "https://sdk.astrada.co/unified/v1/unifiedEnrollmentSdk.js";

export function useUnifiedEnrollmentSdk(): boolean {
  const [ready, setReady] = useState(() => !!window.UnifiedEnrollmentSdk);

  useEffect(() => {
    if (window.UnifiedEnrollmentSdk) return setReady(true);

    const script = document.createElement("script");
    script.src = SDK_SRC;
    script.async = true;
    script.dataset.id = "unified-enrollment-sdk";
    script.addEventListener("load", () => setReady(true));
    document.body.appendChild(script);
  }, []);

  return ready;
}

Finally, open the flow from an event handler:

function EnrollButton() {
  const ready = useUnifiedEnrollmentSdk();

  return (
    <button
      disabled={!ready}
      onClick={() =>
        window.UnifiedEnrollmentSdk.open({
          companyName: "Your company",
          subaccountId: "<subaccountId>",
          getAccessToken: async () => "<access token>", // fetch from your backend
          onEvent: (event) => console.log(event),
        })
      }
    >
      Add cards
    </button>
  );
}

Calling open() again tears down any previous session first, so React StrictMode double-invocation and re-opens are safe. Call open from a Client Component ("use client" in Next.js) because it touches window.

📘

Running alongside the legacy Card Enrollment SDK

The two SDKs are separate scripts with separate globals (CardEnrollmentSdk / UnifiedEnrollmentSdk) and namespaced messages, so both tags can coexist on one page while you migrate. Just don't mount both into the same container.

What your users see

The SDK routes each cardholder from their card's first digits. You don't branch on network, program, or rail:

flowchart TD
    O["UnifiedEnrollmentSdk.open()"] --> E{entry}
    E -- "card (default)" --> CE["Card number entry"]
    E -- "choose" --> CH["Chooser:<br/>Link a bank / Link cards"]
    CH --> BK["Bank connection"]
    CH --> CF["Card form + 3DS"]
    CE --> R{First digits}
    R -- "bulk-capable program" --> BW["Bulk feed wizard"]
    R -- "American Express" --> BK
    R -- "Visa / Mastercard" --> CF

The chooser appears only when your configuration enables more than one first-class rail (see Choose your rails); with a single rail the SDK goes straight to it. Every screen has a back affordance, and closing mid-flow asks for confirmation once the cardholder has entered anything.

The card rail. A Visa or Mastercard enrolls with the card form and 3DS verification:

The bank rail. An American Express card links through the cardholder's bank sign-in; each credit account comes back as an enrolled card:

The bulk rail. When the whole program qualifies, the wizard registers a network feed once and every card on the program enrolls from network data:

The chooser. With bank linking enabled first-class, cardholders pick their path (and can do both, in any order):

Choose your rails (server-side)

Which rails a cardholder sees is configuration, not code. Set enrollmentPolicy on the subaccount (or once on the account as a default for all subaccounts) and every SDK session for that subaccount follows it, with no client release needed.

FieldValuesWhat it controls
cardEnrollmenttrue / falseManual card entry + 3DS verification.
bankEnrollment"off" / "amex-only" / "on"Bank linking. on = first-class chooser tile; amex-only = reachable only via the American Express pivot from card entry; off = disabled (Amex cards then can't enroll unless a bulk program qualifies).
bulkEnrollment"off" / "suggested" / "forced"Network bulk feeds. suggested = wizard offered with a link-just-this-card escape; forced = the wizard is the only path for bulk-capable cards; off = no bulk routing.
linkAnotherCardtrue / falseWhether the card success screen offers Link another card. Admin-managed — ask your Astrada contact to set it, or set it yourself per session with the open() option.
📘

Who wins when values disagree

Each field resolves independently: the subaccount value wins, then the account value, then the open() config on the page, and finally the SDK's built-in default: card-only, everything else off. For the three rails above, server values always beat client values, so you can roll a rail out (or pull one back) from the API alone.

linkAnotherCard is the one exception — it is either-off-hides: the CTA is hidden when the server value is false or the open() option is false, so a server true cannot force the button back past a client opt-out. When neither side sets it, the button shows.

Write it with the same PATCH /subaccounts/{subaccountId} you already use for subaccount settings. Some ready-made shapes:

RecipeenrollmentPolicyCardholders get
Card-only (default)none neededThe classic card form.
Card + Amex pivot{ "cardEnrollment": true, "bankEnrollment": "amex-only", "bulkEnrollment": "off" }Card form; an Amex card automatically pivots to bank linking.
Everything on{ "cardEnrollment": true, "bankEnrollment": "on", "bulkEnrollment": "suggested" }Chooser, card routing, bulk suggestions (the full picture above).
Bulk mandatory{ "bulkEnrollment": "forced" }Bulk-capable programs must use the wizard (no single-card escape).
Bank-only{ "cardEnrollment": false, "bankEnrollment": "on", "bulkEnrollment": "off" }Straight into bank linking; no card form at all.
Example: enable the Amex pivot for one subaccount
PATCH https://api.astrada.co/subaccounts/{subaccountId}
{
  "enrollmentPolicy": {
    "cardEnrollment": true,
    "bankEnrollment": "amex-only",
    "bulkEnrollment": "off"
  }
}
Example: turn everything on for a pilot subaccount
PATCH https://api.astrada.co/subaccounts/{subaccountId}
{
  "enrollmentPolicy": {
    "cardEnrollment": true,
    "bankEnrollment": "on",
    "bulkEnrollment": "suggested"
  }
}
Example: set an account-wide default, then override one subaccount

Account-level values are configured with your Astrada contact during onboarding, and apply to every subaccount that doesn't set its own value. To opt a single subaccount out of a rail, set just that field:

PATCH https://api.astrada.co/subaccounts/{subaccountId}
{
  "enrollmentPolicy": {
    "bulkEnrollment": "off"
  }
}

Absent fields leave the current value unchanged; an explicit null clears the subaccount value so the account default applies again.

Configure the session (client-side)

Everything else about a session is set per open() call:

OptionTypePurpose
subaccountIdstring (UUID), requiredThe subaccount the cardholder enrolls into.
companyNamestring, requiredYour brand name in the welcome copy.
getAccessToken() => Promise<string>, requiredReturns a bearer token minted by your backend.
getAccessTokenTimeoutMsnumberHow long the SDK waits for the token (default 5000).
entry"card" (default) / "choose"Where the flow starts: card-number-first, or the chooser.
cardEnrollment, bankEnrollment, bulkEnrollmentas aboveClient-side rail preferences, applied only where the server policy doesn't set that field.
linkAnotherCardboolean (default true)Whether the card success screen offers Link another card. Set false for one-card-per-session flows; the button is then absent from the DOM, not just hidden. Combines with the server-side policy field as either-off-hides — see Choose your rails.
statusLabelsobjectYour own copy for the four verification-progress stages (see below).
terms{ text, privacyUrl, termsUrl }Your terms-and-conditions block on the card form.
containerHTMLElementMount inline in your element instead of the full-screen modal.
maxWidthnumber | stringMaximum width of the SDK surface — a number is pixels (560560px), a string is passed through ("32rem"). Modal default 480; in container mode the surface fills your element instead.
customerReferenceIdstringYour id, echoed onto the created subscription(s).
metadataobjectReserved. Accepted and validated, but not currently forwarded to the enrollment API, so it has no runtime effect yet.
Example: start on the chooser
UnifiedEnrollmentSdk.open({
  subaccountId: "<subaccountId>",
  companyName: "Your company",
  getAccessToken,
  entry: "choose", // honored when more than one rail is enabled
});
Example: mount inline instead of the modal
UnifiedEnrollmentSdk.open({
  subaccountId: "<subaccountId>",
  companyName: "Your company",
  getAccessToken,
  container: document.getElementById("enrollment-panel"),
});

The iframe fills the container; close() removes the iframe but leaves your element in place.

Example: one card per session

If your own UI already has an "add another card" entry point, turn the SDK's off so the success screen ends the session:

UnifiedEnrollmentSdk.open({
  subaccountId: "<subaccountId>",
  companyName: "Your company",
  getAccessToken,
  bulkEnrollment: false,
  linkAnotherCard: false,
  maxWidth: 560,
});

Two things worth knowing. linkAnotherCard must be a real boolean — any other value is rejected outright, and open() returns false without opening the SDK at all, so "false" as a string fails silently from the user's point of view. And it gates the direct-flow button only: in a session that shows the chooser, or one where the bank rail is on and no bank is linked yet, the success screen offers that next step instead and linkAnotherCard does not remove it.

Migrating from the legacy boolean bulkEnrollment flag

The legacy client flag still works and maps to the tri-state model: bulkEnrollment: false behaves as { cardEnrollment: true, bankEnrollment: "amex-only", bulkEnrollment: "off" } (direct card form with the Amex pivot), and bulkEnrollment: true behaves as { cardEnrollment: true, bankEnrollment: "on", bulkEnrollment: "suggested" } (routed flow with the chooser). Explicit tri-state fields override the mapping field by field.

Make it yours

setStyle({ colors }) themes the SDK with CSS color values. Call it before or after open(), and it sticks across sessions:

UnifiedEnrollmentSdk.setStyle({
  colors: {
    "--btn-primary-bg-color": "#FD4308",
    "--headline-color": "#1C1C3F",
    "--progress-bar-color": "#FD4308",
    "--tile-border-color": "#FD4308",
  },
});
VariableThemes
--btn-primary-bg-colorPrimary buttons (and derived accents).
--headline-colorScreen headlines.
--caption-colorSubheads and captions.
--progress-bar-color / --progress-track-colorThe step progress bar fill / track.
--tile-bg-color / --tile-border-colorThe chooser tiles.
--overlay-bg-colorThe backdrop behind the dialog.
--input-color / --input-placeholder-colorForm input text / placeholder.
--input-border-color / --input-border-color--errorInput borders, normal / error.
--checkbox-bg-color--checkedChecked consent checkboxes.
--terms-link-colorLinks in the terms copy.
Status-chip variables (bulk feed and bank states)
VariableThemes
--chip-success-bg-color / --chip-success-colorSuccess chips (e.g. "Active, cards are flowing").
--chip-warning-bg-color / --chip-warning-colorWarning chips.
--chip-error-bg-color / --chip-error-colorError chips.
--chip-neutral-bg-color / --chip-neutral-colorNeutral chips.

The hosted bank-login window and 3DS challenge pages belong to the bank and the network, so they can't be themed.

statusLabels replaces the copy on the four card-verification progress stages:

KeyDefault label
creatingSubscription"Securely connecting your card"
startingVerification"Contacting your bank"
verifying"Verifying with your bank"
finishing"Finalizing your enrollment"

Listen for results

onEvent is the primary callback and fires for every event as it happens. onSuccess, onError, onCancel, and onConnect are convenience callbacks layered on top:

🚧

onEvent and onSuccess mirror this session; they are not your system of record

Both callbacks reflect what happens inside the confirm window this browser session runs after the cardholder finishes, and if a bank connection's result lands after that window closes, the session can end without ever reporting the card it went on to create. The durable record is server-side: cardsubscription.created fires for every minted bank-feed card, and banklink.completed fires for the connection. Wire those webhooks as your source of truth, the same way the bulk rail's live state defers to networkbulkfeed.statechanged rather than the SDK session.

CallbackFiresPayload
onEvent(event)every event, as it happensa discriminated union: card-enrolled (one per card), bank-linked, feed-created, error, closed, cancelled
onSuccess(result)once, when the session closes with at least one completed action{ cards, bankAccounts, bankLinks, bulkFeeds, summary }: every object created this session, keyed by id
onError(data)on an error, without closing the session{ type: "error", flow, code, message, verificationId?, cardId?, subscriptionId? }
onCancel()only when the session closes with nothing enrollednone
onConnect()the cardholder submits the card form (card flow only)none

The consolidated result: onSuccess

onSuccess fires once, at close, with a map of everything the session created. Objects are grouped by type and keyed by id, so you can assign each one to your cardholder in a single pass. Types the session didn't touch come back as an empty {}.

onSuccess({
  cards: {
    // keyed by cardId
    "cd_9f3a1c72": {
      cardId: "cd_9f3a1c72",
      subscriptionId: "cs_71bdea02",
      enrollmentType: "bank-feed",     // "cardholder-single" | "bank-feed"
      scheme: "AMEX",
      last4: "1008",
      bankAccountId: "ba_88ff10d2",    // present on bank-feed cards
      // typed cards also carry expiryMonth, expiryYear, cardholderName,
      // country, and issuerName (when the eligibility lookup ran)
    },
  },
  bankAccounts: {
    // keyed by account id
    "ba_88ff10d2": {
      id: "ba_88ff10d2", name: "Amex Platinum", mask: "1008",
      institutionName: "American Express", type: "credit",
      cardIds: ["cd_9f3a1c72"],
    },
  },
  bankLinks: { "bl_3c2e6f90": { id: "bl_3c2e6f90" } },
  bulkFeeds: {},   // network bulk feeds, when the session registered any
  summary: { cardsEnrolled: 1, banksLinked: 1, feedsCreated: 0 },
});
KeyContains
cardsEvery card enrolled or materialized this session, keyed by cardId. Each carries the same card fields as the card-enrolled event below (typed cards include expiry, name, and country). Two event-only fields, enrollmentGuidance and authenticationFlow, are not in this map. Capture them from the card-enrolled event.
bankAccountsEvery connected bank account, keyed by account id, with the same fields as the bank-linked event's accounts.
bankLinksEach completed bank connection, keyed by bankLinkId: { id }.
bulkFeedsEach registered network feed, keyed by feedId, with the same fields as the feed-created event.
summarySession counts: { cardsEnrolled, banksLinked, feedsCreated }.

Objects cross-reference by id: a bank-feed card's bankAccountId keys into bankAccounts, whose cardIds link back. Store cardId as your durable key and assign it right away: the card exists before its first transaction on every rail.

📘

Migrating from the Card Enrollment SDK?

The legacy SDK's onSuccess handed you a single card (data.cardId / data.subscriptionId). The unified SDK returns a map: iterate Object.values(result.cards) rather than reading data.cardId. Your webhook and REST handling is otherwise unchanged.

Streaming events: onEvent

Use onEvent to react to each action the moment it completes, rather than waiting for the session to close:

onEvent: (event) => {
  switch (event.type) {
    case "card-enrolled":   // one per card, on every rail
      assignCardToCardholder(event.cardId);
      break;
    case "bank-linked":     // a bank connection completed
      break;
    case "feed-created":    // a network bulk feed was registered
      break;
    case "closed":          // session closed; carries the same maps onSuccess gets
    case "cancelled":       // session closed with nothing enrolled
      break;
  }
};

A host listening on onEvent also receives closed (with the same maps onSuccess gets), so pick one as your terminal signal to avoid double-counting.

card-enrolled: example and fields

One per card, on every rail:

{
  "type": "card-enrolled",
  "cardId": "cd_9f3a1c72",
  "subscriptionId": "cs_71bdea02",
  "enrollmentType": "cardholder-single",
  "scheme": "VISA",
  "last4": "4242",
  "expiryMonth": 12,
  "expiryYear": 2030,
  "cardholderName": "Ada Lovelace",
  "country": "DNK",
  "issuerName": "Example Bank",
  "authenticationFlow": "frictionless"
}
FieldTypeMeaning
cardIdstringThe durable card id. Store this and assign it to your cardholder.
subscriptionIdstring, optionalThe card subscription created for it, present on every rail including bank-feed mints.
enrollmentType"cardholder-single" / "bank-feed" / "network-bulk"How the card enrolled: typed and 3DS-verified, materialized from a bank connection, or registered through a network bulk feed.
alreadyEnrolledboolean, optionaltrue when the card already existed and was reported rather than newly enrolled (a re-submission of an enrolled card).
subscriptionStatestring, optionalThe existing subscription's state, present only alongside alreadyEnrolled (for example "active", "deactivated", "expired", "reqSCA", "creating").
scheme"VISA" / "MASTERCARD" / "AMEX", optionalCard network.
last4, expiryMonth, expiryYear, cardholderName, countrystring / number, optionalDisplay details derived client-side from the form the SDK holds. The PAN never reaches your page.
issuerNamestring, optionalIssuing bank, from the BIN lookup when the routed flow ran.
bankAccountIdstring, optionalOn bank-feed cards: the bank account the card came from (keys into bankAccounts).
authenticationFlow"challenge" / "frictionless" / nullWhether 3DS required a cardholder challenge.
enrollmentGuidanceobject, optionalAdvisory only: availableEnrollmentMethods the card's program supports.
scheme, last4n/aREST and webhook payloads use different names for the same data: scheme here is network there, and last4 here is last4digits on a bank account's cards[] entries (or mask on the account itself). Correlate across surfaces on cardId, which is consistent everywhere.
bank-linked: example and fields

Fires when a bank connection completes, carrying every connected account:

{
  "type": "bank-linked",
  "bankLinkId": "bl_3c2e6f90",
  "bankAccounts": [
    {
      "id": "ba_88ff10d2",
      "name": "Platinum Card",
      "mask": "1008",
      "institutionName": "American Express",
      "type": "credit",
      "cardIds": ["cd_9f3a1c72"]
    }
  ]
}
FieldTypeMeaning
bankLinkIdstringThe completed bank connection, one per sign-in.
bankAccounts[].idstringThe bank account id (keys into onSuccess.bankAccounts).
bankAccounts[].namestringThe account's display name from the institution.
bankAccounts[].maskstring, optionalLast 4 of the account number; for credit accounts, the card's last 4.
bankAccounts[].institutionNamestring, optionalThe institution, e.g. "American Express".
bankAccounts[].typestring, optionalAccount type, e.g. "credit".
bankAccounts[].cardIdsstring[], optionalCards linked to this account, including any card the connection just created (its cardId appears here AND in the top-level cards map); pre-existing cards match by mask, at completion and by a scheduled sweep every few hours.
bankAccounts[].noLinkReasonstring, optionalOn a credit account that linked with no card, the reason. Absent once a card links.

Note the split: bank-linked announces the connection and its accounts; the cards a connection produces each arrive as their own card-enrolled event (with enrollmentType: "bank-feed"). The REST and webhook bank account object also carries a cards[] field (created cards, split out from cardIds); the SDK flattens that instead, surfacing every created card once in the top-level onSuccess.cards map, keyed by cardId with enrollmentType: "bank-feed" and bankAccountId pointing back here.

feed-created: example and fields

Fires when a network bulk feed is registered. Re-registering an existing feed is an idempotent success: the event fires with the existing feed's id and current state.

{
  "type": "feed-created",
  "feedId": "nbf_5b8d2f11",
  "network": "MASTERCARD",
  "networkReference": "G1234567",
  "state": "submitted"
}
FieldTypeMeaning
feedIdstringThe registered network bulk feed.
network"VISA" / "MASTERCARD" / "AMEX"Which network delivers the feed.
networkReferencestringThe program reference that was registered (Mastercard Delivery ID, Visa RPIC/PIC, …).
statestringFeed lifecycle state. Activation happens on the network side, usually within a few business days. Watch the networkbulkfeed.statechanged webhook rather than the SDK session.
error: example and fields

Errors don't close the session. The cardholder can retry or pick another path, and you get the event for logging or support:

{
  "type": "error",
  "flow": "card",
  "code": "verification_failed",
  "message": "The card could not be verified.",
  "verificationId": "cv_0f52ab90",
  "cardId": "cd_9f3a1c72",
  "subscriptionId": "cs_71bdea02"
}
FieldTypeMeaning
flow"card" / "bank" / "feed" / "session"Which rail (or the session itself) the error happened in.
codestringStable, machine-readable reason.
messagestringHuman-readable description. Display or log it, don't parse it.
verificationIdstring, optionalThe 3DS verification, when the card flow got that far. Quote it when contacting support.
cardId, subscriptionIdstring, optionalPresent when the flow had already created them.
alreadyEnrolledboolean, optionaltrue when the failure is that the card is already enrolled and unusable for a new enrollment.
subscriptionStatestring, optionalThe existing subscription's state, present alongside alreadyEnrolled (for example "active", "deactivated", "expired").

What you get back, per rail

Whatever rail a card takes, it lands in the same place: a durable cardId, a card subscription announced by cardsubscription.created, and transactions that carry the cardId. The only per-rail difference is the subscription's enrollmentType (cardholder-single, bank-feed, or network-bulk) and its back-reference (bankAccountId or networkBulkFeedId). Handle enrollment once and read enrollmentType as a label, never as a branch. See Unified Card & Bank Feeds and Webhook Event Types.

Supported browsers

The embedded app targets es2020-era engines: Chrome/Edge 88+, Firefox 78+, Safari 14+. The loader script itself compiles lower (es2017) since it executes on your page. Button hover shading uses color-mix() and degrades gracefully on older engines. Cross-engine coverage (WebKit and Firefox, including the bank-window popup flow) runs in the SDK's test suite.

Availability

The unified SDK is in preview. Card-number enrollment is available today; the bank-connection and bulk rails are enabled per account. Talk to your Astrada contact before integrating.

Related


Did this page help you?