A single request activates a whole run of cards at once. The caller names the first and last card of a contiguous batch plus its sequence range; axis-service expands that into every activatable card in the range and processes them together. It runs in two phases — a synchronous prepare that validates and stages the group, then a deferred execute that drives the per-card Tribe activation. Most of the interesting logic lives in prepare, which puts every request through a gauntlet of structural and semantic checks — including the cross-batch guard that stops a range from quietly reaching across batch boundaries.
POST /api/cpm/v1/partners/{partnerExtId}/cards/multi/actions/{prepare|execute|findOne|cancel}
prepare validates and stages an ActionRequestEntity (status New); execute commits it. The generic contract — the New → Processing → Complete/Partial/Failed state machine, deferred execution, clientRef idempotency and the expiry timers — lives in Actions & deferred tasks. This doc covers what's specific to group activation: the validation gauntlet (§02), the cross-batch sequence guard (§03), and the per-card activation work (§04).
CardSelector — two boundary cards bracket a sequence range
You don't enumerate every card. You give the first and last card of the batch (by id or external ref) and the sequence numbers that bracket them. axis-service resolves the membership: every activatable, same-design card whose sequence number falls in [seqLow, seqHigh]. cardCount declares how many you expect.
Selector must carry exactly 2 cardIds or 2 cardExtRefs — the first and last card. Anything else is rejected before any DB work.
recognizeConflict() on each boundary card — a card already inside an open action can't be re-grouped.
validateSeqNoMismatch() — the first card's real sequence number must equal seqLow, the last card's must equal seqHigh. The caller can't claim a range its boundary cards don't actually sit on.
findAllGroupCardIds(seqLow, seqHigh, designId, action) — pulls every card of the first card's designId, in range, whose latest status is valid for the action. This set is the group.
operations[], each its own range — CardAccountMultiActionValidator caps the array at 20 and rejects any two operations whose sequence ranges overlap.
Each gate is a check; failing one exits with a specific HTTP status + errorCode. Codes confirmed against the live release-test suite.
Validation is layered: the validator layer (CardAccountActionValidator) checks the request's shape and provenance before any group is built; the task layer (GroupCardAccountActionTask.prepare) checks the resolved group against what the caller asked for. Several semantic failures share the mismatched code (417 · 2309) — the response message is what tells them apart.
cardIds / cardExtRefsA group selector must name its boundary cards.
Exactly first + last. Not 1, not 3.
designIdFirst and last card must be the same program and the same design — the design is the batch's identity here.
seqLow / seqHighEach boundary card's stored sequence number must equal the range it claims.
Multi-op requests: no two ranges may intersect.
clientRefIdempotency guard — a ref can be prepared once.
validateCardCountMatchesRequestActivatable cards found in range == the caller's cardCount.
validateCrossBatchActivation(seqHigh − seqLow + 1) == cardsFound. The window must be a solid run — no reaching across a gap into another batch. Detailed in section 03.
validateProgramCurrencyMatchesRequestFor load actions, the requested currency must be the program's home currency.
validateIfAllCardsHaveSameProgramEach expanded id loads and belongs to the program; a missing id is a hard not-found.
An action_request (status New) is created, the group is written to group_card_action, and a New DTO with actionGroupId returns. A 120s timer is armed to cancel if execute never comes.
What "cross-batch" means, and how a count check enforces it — GroupCardAccountActionTask.validateCrossBatchActivation
There is no batch identifier in the data. A batch is simply a block of cards minted together, which lands as a contiguous run of sequence numbers sharing one design_id. The same design can be reused for later mint runs, so its sequence space looks like dense islands separated by gaps — each island a batch. Sequence numbers are scoped per design, so the design_id filter in findAllGroupCardIds is what isolates the right design in the first place; this guard then enforces that the chosen range stays inside a single island.
seqLow=101, seqHigh=105 → width 5, found 5 → 5 == 5. A solid island.seqLow=101, seqHigh=114 → width 14, found 10 → 14 ≠ 10. The gap exposes the cross-batch span.The two checks work together. cardCount == found on its own would let a caller set cardCount to the 10 cards actually found and pass — silently grouping two batches and skipping whatever sits in the gap. Requiring width == found as well rules that out: the only way to satisfy both is a single gapless run.
seqLow/seqHigh to the run you mean.
multi/actions/execute → GroupCardAccountActionTask.execute() → GroupCardActionHandler (background)
execute schedules a deferred task and returns Processing at once; the per-card work runs later on a background worker. The deferral itself — the persisted queue, the scanner, the 4-thread pool, clientRef idempotency and the 60s/120s expiry timers — is the standard action mechanism, documented in Actions & deferred tasks. What follows is what GroupCardActionHandler does once it picks the group up.
Pull the group from group_card_action (status New) in pages of up to 100 and queue them. For load actions the whole batch amount is moved to a staging account first (see the money path below).
Per card: Cards.activateCard(spiCardId, ref) sets the card live on Tribe — each card in its own transaction, so one card failing can't roll back the rest.
addStatusUpdate() writes a card_update_activity row of Activated. If the program requires a KYC registration lock, the card is also flagged kyc_locked and pushed to Tribe status T (Suspended) — activated, then held for KYC.
Each group_card_action row goes Complete, or Failed with the error attached. When the pages drain, the action settles by completed-vs-total count — its cardCount rewritten to the number that succeeded — and a callback fires. A batch with some failures is a normal Partial Complete; only an all-fail batch is Failed.
| action | before | after (no KYC lock) | after (KYC reg-lock) | tribe calls |
|---|---|---|---|---|
activate | not activated | activated | suspended (T) + kyc_locked | activateCard [+ changeCardStatus T] |
activateWithLoad | not activated | activated + funds loaded | suspended (T) · lock holds the load | activate + load [or KycLock] |
FundTransferEntry + tran_reference), loads each card from staging, then reverses the funds for any card that failed back to the program account. Plain activate moves no money.
access_scope_json.cardIdsThe same prepare/execute + sequence-range machinery serves a family of bulk actions. Each declares the card statuses it will accept (group-action-types.properties); the range guard applies to all of them.
| action type | accepted starting status |
|---|---|
activate | not activated |
activateWithLoad | not activated |
load | activated |
suspend | activated risk lost fraud suspend |
unsuspend | suspended |
markLost | activated suspended risk fraud lost |
markStolen | activated suspended risk not activated lost stolen |
unmarkLost | lost |