Wallet Provisioning OTP Delivery

draftThe card-platform implementation of the holderAuthentication OTP: events-ingest acks fast, then a worker resolves contact via Tribe getHolder and delivers by SMS or email.· 2026-06-07
0

What This Builds

This is the card-platform build behind the Wallet Provisioning OTP flow. SetldPay is deliver-onlyDeliver-onlySetldPay's role here: send the code by SMS or email; never generate or validate it.: we receive Tribe's holderAuthenticationholderAuthenticationThe Tribe TAI notification carrying the OTP, the channel, and the wallet — the trigger for this flow. notification, resolve the cardholder, and send the code — Tribe owns generation and validation. It lands as card-platform's first workerWorkerThe card-platform background process that consumes the queue and runs delivery — this OTP job is its first., establishing the ack-fast / deliver-async spine (events-ingestevents-ingestcard-platform's Tribe webhook ingress — decrypt, validate, persist, enqueue, ack. No business logic.pg-bosspg-bossThe Postgres-backed job queue that decouples the fast webhook ack from the slower OTP delivery. → worker → providers) that the 3DS TOTP flow, KYC polling, and the callback outbox will all reuse.

1

Architecture — Ingest, Queue, Worker

One spine: ingest acknowledges Tribe in milliseconds and hands off; the worker does the slow, external work. A slow SMS or email provider can never threaten the webhook response.

↓ the ack returns at events-ingest in milliseconds — everything right of the queue runs async ↓
Tribe (external)events-ingest + pg-boss (fast ack)worker (async delivery)SMS / email provider

The worker has two side dependencies: an outbound Tribe getHoldergetHolderThe Tribe Program-Manager API call that returns the cardholder's real phone and email. call for the cardholder's contact, and the card-platform database to persist the notification and each delivery attempt.

2

Ingress — events-ingest

events-ingest already handles auth, settlement, and card-status notifications (persist + ack). For this feature we add a notify-holder-auth schema and a holder-authentication event type to shared/tribe-client, plus a handler that persists, then enqueues — the platform's first enqueue — then acks. No provider call here.

ts
// shared/tribe-client — register the new notify type + schema
export const TribeNotifyEventTypes = [
  'auth', 'settlement', 'card-status-change',
  'holder-authentication',            // <- new
] as const;

// card-platform/events-ingest — persist, then ENQUEUE, then ack
const holderAuthentication: TribeWebhookHandler = async (parsed, ctx) => {
  const id = await persistHolderAuth(prisma, parsed.payload, ctx);
  await queue.send('holder-auth-otp', { notificationId: id }); // <- first enqueue
  return { status: 'success' };                                // <- fast ack (ms)
};
3

The Worker

A new packages/card-platform/workers consumes the pg-boss holder-auth-otp queue. The job is idempotent on authRequestId (with notificationSequence) so Tribe retries or duplicate enqueues never double-send.

dequeue Load the job

Read the persisted notification by id.

getHolder Resolve contact

Tribe getHolder(holderId, cardId) → phone + email; backfill language from the program default.

select Pick the channel

authenticationMethod 3 → SMS, 4 → email; 1 / 2 are a logged no-op.

render Render copy

Apple Pay Issuer FR v3.5 rules; wallet name from walletProviderId; expiry from requestExpiresAt.

send Deliver

Send via the pluggable SMS provider or email, and record an OtpDeliveryAttempt.

Reliability

  • Idempotent on authRequestId — the worker checks delivered state before sending, so Tribe retries (same id, higher notificationSequence) never double-send.
  • Retry with backoff — pg-boss retryLimit + exponential backoff for transient provider errors.
  • Expiry guard — skip the send when now ≥ requestExpiresAt and mark the notification expired; never deliver a stale code.
  • Dead-letter — once retries are exhausted the job dead-letters, with a failed OtpDeliveryAttempt recorded and an alert raised.

A durable queue + idempotency is the right tool here: deliver-only has no long-lived, await-external state, so a workflow engine would be over-engineering — revisit only if an await-external flow (e.g. full 3DS validation) ever lands.

4

Resolving Contact — getHolder

Contact is not in the platform database, and there is no outbound Tribe client yet — so this adds the platform's first outbound Tribe Program-Manager call, getHolder, using a single card-platform credential set to start.

getHolder fieldUse here
phoneNumber SMS target — format to E.164 with countryIson
email Email target
firstName / lastName Email greeting
countryIson Phone country / formatting
(no language field) Backfill — per-program default locale (Phase 2)
5

Channels and Templates

Branch on authenticationMethod (3 → SMS, 4 → email). The wallet name (Apple / Google) comes from walletProviderId, and the expiry shown to the cardholder is computed from requestExpiresAt rather than hardcoded.

How the copy varies

AxisVariesHow
Channel Yes SMS is short, OTP-first; email has subject, body, imagery.
Language / locale Yes Per-locale copy. Phase 1 English; Phase 2 adds locales.
Wallet (Apple / Google) Yes Wallet name + email imagery differ; FR rules are wallet-specific.
Program Substitutions Card name, issuer, from-address, contact — not the template structure.

Render-time variables: {otp} · {wallet} · {cardName} · {issuer} · {expiryMinutes} · {contact} — plus the email from-address and Wallet / Apple Pay imagery.

Apple Pay Issuer Functional Requirements v3.5 — copy rules
ChannelApple Pay Issuer FR v3.5 rules
SMS OTP is the first number in the message; the text states it is for adding a card to the wallet; "do not share".
Email Prominent code + where to enter it; Wallet icon and Apple Pay logo imagery; an issuer "from" address.

Copy lives in MessageTemplate rows keyed by channel × wallet × locale, each effective-dated (effectiveFrom / effectiveTo) so changes can be scheduled and the history preserved — the active row is chosen by now at send time. Per-program substitution values come from ProgramMessagingConfig (also effective-dated). Every send records the resolved templateId + version on the OtpDeliveryAttempt, so we can reconstruct exactly what copy went out.

The SMS sender is a pluggable interface — the EU-cost-effective provider is still to be confirmed (not Twilio). Email is expected via SendGrid, pending provisioning.

6

Data Model

Five entities — two runtime, one outcome, two reference — mirroring the existing per-notification Prisma pattern (TribeAuthNotification, etc.).

TribeHolderAuthNotification
card-platform / db
  • authRequestId — unique (idempotency)
  • parsed message + notificationSequence
  • status: received → delivered / failed / expired
  • no validationValue at rest
OtpDeliveryAttempt
card-platform / db
  • FK → notification · channel · wallet · locale
  • masked target · provider · providerMessageId
  • templateId + version actually used
  • status · error · attemptedAt
HolderAuthOutcome
card-platform / db
  • the holderAuthenticationNotification result
  • authRequestId (FK) · confirmationStatus
  • receivedAt — correlates delivery → outcome
MessageTemplate
card-platform / db
  • channel × wallet × locale · version
  • effectiveFrom / effectiveTo
  • SMS body / email SendGrid template id
ProgramMessagingConfig
card-platform / db
  • per cardProgramId, effective-dated
  • cardName · issuer · fromAddress · contact
  • defaultLocale · brand assets
7

Security

  • Decrypt: reuse the tribe-client AES‑256‑CBC + RSA x-sign path; per-BIN apiId key selection (single credential set for now).
  • Integrity: validate x-hook-signature — the gap called out in Ingress above.
  • Secret handling:validationValue is the live OTP — never logged, and not persisted at rest (or short-TTL only). It lives just long enough to render the message.
8

Open Decisions

Settled: deliver-only · build on card-platform (not the auth hot path) · contact via Tribe getHolder · a single PM credential set · handle authenticationMethod 3 and 4 · English-first.

Open items below — several need Tribe or product input before we build.

Open itemOwnerNote
Email from-address Product Issuer-style servicing address (open on DEV-569).
Card / product name in copy Eng / Product Not in getHolder — per-program config or getCardDetails.
Device flavor in copy Eng / Product iPhone / iPad / … not in the TAI message — likely device-agnostic.
SMS provider Team Cheaper-EU provider (not Twilio); pluggable behind an interface.
Email provider + Apple assets Eng / Product SendGrid provisioning + Wallet / Apple Pay imagery.
authenticationMethod = 1 policy Tribe Does Tribe send generic 1 for our programs, and which channel?
"validates + calls back" Eng Target-arch doc implies more than deliver-only; we are deliver-only.
Confirm notify endpoint Eng Confirm Tribe targets events-ingest so no migration is needed first.