Notification Center — OTP contract (card-core → gateway)
Everything card-core sends when it needs a one-time code delivered, so the Notification Center can be implemented (or verified) against it without reading this repo. Card-core is the only caller described here; it never receives callbacks.
Base URL comes from NOTIFICATION_CENTER_ORIGIN (no trailing slash required —
card-core strips one). Unset in dev = no call at all, the code is logged instead.
1. The two endpoints
| Endpoint | Called by | When |
|---|---|---|
POST {base}/otp | every generic OTP flow, and the payment flow for non-VND | password reset, account linking, card reveal/activate, verification update |
POST {base}/otp/purchase | payment verification | only when the authorization currency is VND — the amount-aware template |
Non-VND payment OTPs fall back to plain /otp deliberately: an amount rendered
without its currency is worse than no amount.
2. Request
Headers: Content-Type: application/json. Nothing else — no API key, no
signature (see §7).
2.1 SMS
POST /otp
{
"channel": "SMS",
"otp": "482913", // 6 digits, leading zeros preserved — always a STRING
"country_calling_code": "84", // no '+', no leading zeros
"national_phone_number": "901234567" // no leading 0
}
The split is mechanical (splitPhone): card-core stores E.164 (+84901234567)
and cuts it into the two fields. VN is the only market today — a number with no
country code is assumed 84.
2.2 Email
POST /otp
{
"channel": "EMAIL",
"otp": "482913",
"email": "owner@acme.vn" // lowercase, already normalized
}
channel is the discriminator: SMS carries the two phone fields and no
email; EMAIL carries email and neither phone field. A validator with
forbidNonWhitelisted must accept both shapes.
2.3 Purchase (amount-aware, SMS only)
POST /otp/purchase
{
"channel": "SMS",
"otp": "482913",
"amount": 150000, // whole VND, JS number — decimals truncated, never a string
"meta": { "locale": "vi" }, // from OTP_SMS_LOCALE, default "vi"
"country_calling_code": "84",
"national_phone_number": "901234567"
}
amount and meta appear only on this path. The plain /otp contract does
not declare them, so a strict validator would 400 on an OTP nobody could then
answer.
3. Response
Card-core checks only the HTTP status:
2xx→ delivery treated as successful, the challenge is committed.- anything else →
otp_delivery_failed(502 to the API caller) and the challenge is rolled back — the code is destroyed, the user must start over.
The response body is never parsed. Return whatever is convenient; do not rely on card-core reading an id or a status from it.
Timeout: 4000 ms. Past that the request is aborted and treated as a failure. The gateway must answer inside 4 s — accept-and-queue is fine, and preferable to holding the connection while a carrier responds.
There are no retries. Card-core sends once. A 5xx means the user retries the whole flow and a new code is generated; the old one never becomes valid, so a late delivery of it is harmless but confusing.
The consistency edge worth designing for: if the gateway sends the message and then answers non-2xx (or takes >4 s), card-core rolls the challenge back — the person receives a code that can never be verified. Prefer answering 2xx as soon as the message is accepted for delivery.
4. What the gateway owns
Card-core sends only the code (plus amount/locale on the purchase path).
The message text is entirely the gateway's: template, language, sender id,
subject line for email.
Two consequences to plan around:
- Card-core does not send a purpose. A password-reset code and an
account-link code arrive as identical
/otprequests. If distinct templates per flow are wanted (they are, for email — "reset your password" vs "merchant X wants to link your account"), card-core has to start sending a discriminator. It does not today; say so and we will add one field. - Card-core does not send the TTL. Any "expires in N minutes" in a template is hardcoded on the gateway side and must match these:
| Flow | Env | Default |
|---|---|---|
| generic (reset, link, cardholder actions) | OTP_CHALLENGE_TTL_SECONDS | 300 s (5 min) |
| payment verification | OTP_TTL_SECONDS | 300 s (5 min) |
Change one without the other and the SMS lies about its own expiry.
5. Volume and rate limits
Card-core throttles before it ever calls, so the gateway sees a filtered stream:
- 3 challenges per subject per 10 minutes (a person, a card, or a hashed handle). A fourth is a 429 to the API caller, no call to the gateway.
- Per-endpoint HTTP throttles on top (e.g. 5/min on password-reset request, 10/min on login-adjacent routes).
- Payment resends:
OTP_RESEND_COOLDOWN_SECONDS(30 s) andOTP_MAX_RESENDS(2).
Bursts are therefore small and bounded, but they are per subject — a large merchant onboarding staff can still produce many distinct subjects at once.
6. Security expectations
- The 6-digit code travels in plaintext in the request body. Card-core stores
only
sha256(challenge_id + ':' + code)and never logs the code in production. The gateway should hold the same line: do not logotp, and do not persist it beyond what delivery needs. - Delivery destinations are the person's real email/phone. Treat them as PII in
logs (card-core masks them:
****4567,o***@acme.vn). - The call is currently unauthenticated — no API key, no HMAC, no mTLS. Whatever can reach the gateway can send OTPs to arbitrary numbers and emails at card-core's expense. If the gateway is not on a private network, this needs a shared secret header or mTLS; card-core adding one is a small change, and the gateway side should decide the shape.
7. Checklist for the gateway implementation
- Accept both
channel: "SMS"andchannel: "EMAIL"onPOST /otp. - Validate loosely: unknown fields must not 400 the request (
metaandamountarrive only on/otp/purchase, but be tolerant). - Answer within 4 s, 2xx as soon as the message is accepted.
- Never log
otp; mask destinations. - Keep template expiry text in sync with §4's TTLs.
- Tell us if you need a
purpose/templatefield — card-core does not send one yet, and email flows will want it.
8. Reference implementation in this repo
src/common/otp/otp-delivery.service.ts— the generic sender (both channels)src/merchant/otp.service.ts— the payment sender (/otp/purchase, amount + locale), a deliberate copy because its delivery runs inside the pending-auth transactionsrc/common/otp/otp-challenge.service.ts— challenge lifecycle: TTL, 3 attempts, velocity, and which subject resolves to which channel