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, the same durable cardId, and the same 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 — there is no npm install. 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 declare the global so TypeScript knows about UnifiedEnrollmentSdk.

First, a type declaration in any .d.ts your tsconfig picks up:

interface UnifiedEnrollmentSdkConfig {
  companyName: string;
  subaccountId: string;
  getAccessToken: () => Promise<string>;
  getAccessTokenTimeoutMs?: number;
  entry?: "card" | "choose";
  container?: HTMLElement;
  onEvent?: (event: { type: string } & Record<string, unknown>) => void;
  onSuccess?: (result: Record<string, unknown>) => void;
  onError?: (error: Record<string, unknown>) => void;
  onCancel?: () => void;
}

declare global {
  interface Window {
    UnifiedEnrollmentSdk: {
      open: (config: UnifiedEnrollmentSdkConfig) => void;
      close: () => void;
      setStyle: (style: { colors: Record<string, string> }) => void;
    };
  }
}

export {};

Then load the script — either keep the <script> tag in your index.html, or inject 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 — 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.
📘

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. Server values always beat client values, so you can roll a rail out (or pull one back) from the API alone.

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.
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.
customerReferenceIdstringYour id, echoed onto the created subscription(s).
metadataobjectArbitrary key/values carried with the session.
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.

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 — 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:

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
    },
  },
  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 — the same fields as the card-enrolled event below.
bankAccountsEvery connected bank account, keyed by account id — 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 — 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. Absent only on bank-derived cards materialized without one.
enrollmentType"cardholder-single" / "bank-feed"How the card enrolled: typed and 3DS-verified, or materialized from a bank connection.
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.
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 — matched by mask immediately, and by transaction discovery over time. A link, not a new enrollment.

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").

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.

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?