Chuyển tới nội dung chính

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​

EndpointCalled byWhen
POST {base}/otpevery generic OTP flow, and the payment flow for non-VNDpassword reset, account linking, card reveal/activate, verification update
POST {base}/otp/purchasepayment verificationonly 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 /otp requests. 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:
FlowEnvDefault
generic (reset, link, cardholder actions)OTP_CHALLENGE_TTL_SECONDS300 s (5 min)
payment verificationOTP_TTL_SECONDS300 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) and OTP_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 log otp, 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​

  1. Accept both channel: "SMS" and channel: "EMAIL" on POST /otp.
  2. Validate loosely: unknown fields must not 400 the request (meta and amount arrive only on /otp/purchase, but be tolerant).
  3. Answer within 4 s, 2xx as soon as the message is accepted.
  4. Never log otp; mask destinations.
  5. Keep template expiry text in sync with §4's TTLs.
  6. Tell us if you need a purpose/template field — 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 transaction
  • src/common/otp/otp-challenge.service.ts — challenge lifecycle: TTL, 3 attempts, velocity, and which subject resolves to which channel