Wallet Provisioning OTP Delivery
What This Builds
This is the card-platform build behind the Wallet Provisioning OTP flow. SetldPay is
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 worker has two side dependencies: an outbound Tribe
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.
// 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)
};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, highernotificationSequence) never double-send. - Retry with backoff — pg-boss
retryLimit+ exponential backoff for transient provider errors. - Expiry guard — skip the send when
now ≥ requestExpiresAtand mark the notificationexpired; never deliver a stale code. - Dead-letter — once retries are exhausted the job dead-letters, with a failed
OtpDeliveryAttemptrecorded 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.
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 field | Use here |
|---|---|
| phoneNumber | SMS target — format to E.164 with countryIson |
| Email target | |
| firstName / lastName | Email greeting |
| countryIson | Phone country / formatting |
| (no language field) | Backfill — per-program default locale (Phase 2) |
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
| Axis | Varies | How |
|---|---|---|
| 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.
| Channel | Apple 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". |
| 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.
Data Model
Five entities — two runtime, one outcome, two reference — mirroring the existing per-notification Prisma pattern (TribeAuthNotification, etc.).
authRequestId— unique (idempotency)- parsed message +
notificationSequence status: received → delivered / failed / expired- no validationValue at rest
- FK → notification · channel · wallet · locale
- masked target · provider · providerMessageId
templateId+ version actually used- status · error · attemptedAt
- the holderAuthenticationNotification result
authRequestId(FK) · confirmationStatus- receivedAt — correlates delivery → outcome
- channel × wallet × locale · version
effectiveFrom/effectiveTo- SMS body / email SendGrid template id
- per
cardProgramId, effective-dated - cardName · issuer · fromAddress · contact
- defaultLocale · brand assets
Security
- Decrypt: reuse the tribe-client AES‑256‑CBC + RSA
x-signpath; per-BINapiIdkey selection (single credential set for now). - Integrity: validate
x-hook-signature— the gap called out in Ingress above. - Secret handling:
validationValueis the live OTP — never logged, and not persisted at rest (or short-TTL only). It lives just long enough to render the message.
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 item | Owner | Note |
|---|---|---|
| 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. |