Skip to content

Data flows — ZevID

Where data enters ZevID, where it lives, where it leaves. Each diagram answers one DPO question. Cross-cutting flows (across multiple products) live at Cross-product / data flows; this file is ZevID's internal perspective.

Reading order

Start with Signup, then Login. The rest build on those.

Signup

A new user creates their first ZevID account. Triggered by a signup at any Zev product (the product redirects to accounts.zevop.com). After signup the account belongs to ZevID centrally; every product reads identity from here.

sequenceDiagram
  autonumber
  participant U as User browser
  participant Z as ZevID API
  participant E as ZeptoMail
  participant DB as ZevID DB

  U->>Z: POST /v1/auth/signup<br/>(email, password, names, clientId)
  Z->>DB: INSERT into accounts<br/>(bcrypt hash; isEmailVerified=false)
  Z->>DB: INSERT into otps<br/>(6-digit code, 10-min TTL)
  Z->>E: Send "verify your email" template
  Z->>DB: INSERT into login_events<br/>(signup_initiated, IP, UA)
  Z-->>U: 200 OK
  U->>Z: POST /v1/auth/verify-otp (code)
  Z->>DB: UPDATE accounts SET is_email_verified=true
  Z->>DB: INSERT into sessions<br/>(refresh-token hash, UA, IP, location)
  Z->>DB: INSERT into login_events (login_success)
  Z-->>U: identity token + refresh cookie

Notes

  • Password validation: isPasswordValid (zevid-frontend/src/utils/passwordRules.ts) enforces strength on the frontend; backend re-checks. Plaintext is never persisted — only the bcrypt hash (cost 12).
  • First-name + last-name run through @IsSafeName() to block spam-laundering attack patterns (see ZevID's security.md).
  • The OTP is generated by OtpService.generateOtp (src/modules/auth/otp/otp.service.ts:23) with channel email, purpose login.
  • IP + UA come from the request; UA is parsed into platform/browser/OS via ua-parser-js.

Login (OTP + MFA)

User has an existing account, signs in with email + OTP. If MFA is set up, an extra factor is required.

sequenceDiagram
  autonumber
  participant U as User browser
  participant Z as ZevID API
  participant E as ZeptoMail
  participant T as Termii
  participant DB as ZevID DB

  U->>Z: POST /v1/auth/login (email, password)
  Z->>DB: SELECT accounts WHERE email
  Z->>Z: bcrypt.compare(pw, account.password_hash)
  alt password OK
    Z->>DB: INSERT otps (6-digit, channel=email)
    Z->>E: Send "sign-in code" template
    Z->>DB: login_events (otp_sent)
    Z-->>U: { otpRequired: true }
  else password fail
    Z->>DB: login_events (login_failed)
    Z-->>U: 401
  end

  U->>Z: POST /v1/auth/verify-otp (code)
  Z->>Z: getAvailableFactors(accountId)
  alt no MFA enrolled
    Z->>DB: INSERT sessions + login_events (login_success)
    Z-->>U: tokens
  else MFA required (TOTP or SMS or both)
    Z-->>U: { requiresMfa: true, mfaToken, availableFactors }
    alt user picks SMS
      U->>Z: POST /v1/auth/mfa/send-sms (mfaToken, phoneId)
      Z->>DB: INSERT otps (channel=sms, purpose=mfa_sms:<phoneId>)
      Z->>T: Send SMS code
      Z-->>U: { phoneHint }
    end
    U->>Z: POST /v1/auth/verify-mfa (code, factor, phoneId?)
    Z->>DB: INSERT sessions + login_events (login_success)
    Z-->>U: tokens
  end

Notes

  • The mfaToken is short-lived (5 min) and stored hashed in authorization_codes; it proves "credentials passed" between the credential-verification step and the second-factor step.
  • SMS MFA uses the same OTP machinery as phone verification, namespaced via the purpose field (mfa_sms:<phoneId>).
  • Geo-fencing + IP-allowlist checks fire before successful login is granted; failures land in login_events as login_blocked_geo / login_blocked_ip.

Phone verification

The first time a user adds a phone. Used for SMS sign-in alerts + SMS MFA + product-side step-up.

sequenceDiagram
  autonumber
  participant U as User (logged-in)
  participant Z as ZevID API
  participant T as Termii
  participant DB as ZevID DB

  U->>Z: POST /v1/account/phones (raw phone)
  Z->>Z: parsePhoneNumberFromString → E.164
  Z->>Z: AES-256-GCM encrypt + SHA-256 hash with PHONE_HASH_PEPPER
  Z->>DB: INSERT account_phones (encrypted, hash, last2, isPrimary=false, verifiedAt=null)
  Z->>DB: INSERT otps (channel=sms, purpose=phone_verification:<phoneId>)
  Z->>T: Send SMS code
  Z->>DB: login_events (phone_added)
  Z-->>U: { phoneId, phoneHint }
  U->>Z: POST /v1/account/phones/:phoneId/verify (code)
  Z->>Z: assert no other accounts share hash (cap-of-3)
  Z->>DB: UPDATE account_phones SET verified_at=now, isPrimary=if-first
  Z->>DB: UPDATE accounts SET phone=<E.164>  (legacy mirror)
  Z->>DB: login_events (phone_verified)
  Z-->>U: { verified: true, isPrimary }

Notes

  • Cap-of-3: at most 3 distinct verified accounts may hold a row for the same phone hash. Enforced at verify-time in account-phones.service.ts → ensureUniquenessCap.
  • Once verified, the primary phone is mirrored to the legacy accounts.phone column so older readers (KYC, login alerts) still see it.

KYC outcome write (inbound from ZevPay)

ZevPay (or any KYC-running product with the right scope) writes the canonical KYC verdict into ZevID.

sequenceDiagram
  participant P as KYC-running product (e.g. ZevPay)
  participant Z as ZevID API
  participant DB as ZevID DB

  P->>Z: POST /v1/internal/kyc-report<br/>(service-key auth, scope=kyc.report)<br/>{ accountId, verifiedFirstName, verifiedLastName, dateOfBirth, bvnHash, ninHash, faceMatch }
  Z->>Z: assert caller has scope kyc.report
  Z->>DB: UPSERT kyc_verifications (account_id unique)
  Z->>DB: UPDATE accounts SET first_name, last_name = verifiedFirstName, verifiedLastName
  Z-->>P: 200 OK

Notes

  • BVN / NIN are sent as hashes by the KYC-running product. ZevID never receives or stores plaintext BVN/NIN.
  • The KYC outcome is read back by other products via POST /v1/internal/kyc-status (no consent required — "real human anywhere" baseline) or via the per-product enrollment metadata for the granular tier (enrollments[zevpay].metadata.personalKycLevel).

Enrollment sync (inbound from every product)

Every Zev product owes ZevID a per-(user × product) sync whenever the user enrolls, changes tier, or gains/loses a sub-entity (business, store, project, org). See cross-product / enrollment-sync for the contract.

sequenceDiagram
  participant P as Product (any Zev product)
  participant Z as ZevID API
  participant DB as ZevID DB

  Note over P: Trigger: user enrolled, tier changed, entity added/removed
  P->>Z: PUT /v1/internal/users/:accountId/enrollments/:productClientId<br/>(service-key auth, scope=users.enroll)<br/>{ tier, metadata, isActive }
  Z->>Z: assert caller.clientId == :productClientId
  Z->>DB: UPSERT product_enrollments (unique on accountId + product)
  Z->>Z: emit user.product.tier.changed webhook<br/>(to subscribed Zev products)
  Z-->>P: 200 OK

Notes

  • tier and metadata are opaque to ZevID — each product owns its shape.
  • Scope-binding: the calling service-key's clientId must equal :productClientId. ZevPay can't write ZevCommerce's row.

A product needs to act on a user's behalf and lacks an existing zpip_scope_grants row. The user lands on ZevID's consent screen.

sequenceDiagram
  autonumber
  participant Caller as Calling product
  participant Z as ZevID API
  participant U as User browser
  participant DB as ZevID DB

  Caller->>Z: POST /v1/internal/oauth/token-exchange<br/>(scope or scopes[], audience, accountId, consent_required:true, return_to)
  Z->>DB: check zpip_scope_grants for active rows
  alt all scopes already granted
    Z-->>Caller: access_token (single) or { all_granted:true } (multi)
  else missing one or more
    Z->>DB: INSERT zpip_consent_requests (scopes, returnTo, expiresAt=now+10m)
    Z-->>Caller: { error: consent_required, consent_url, scopes }
    Caller->>U: redirect to consent_url
    U->>Z: GET /v1/account/zpip/consent/:requestId
    Z-->>U: render data (caller + target product info + per-scope rows)
    U->>Z: POST .../approve { approvedScopes, metadata }
    Z->>DB: INSERT zpip_scope_grants (one per approved scope)
    Z->>DB: UPDATE zpip_consent_requests (status=approved, grantIds[])
    Z->>Caller: emit user.consent.granted webhook
    Z-->>U: redirect to return_to?granted=…&denied=…
  end

Notes

  • Owner-product constraint: all scopes in a batched scopes[] must share the first dotted segment.
  • After approval, tokens are still minted per-scope via subsequent single-scope token-exchange calls. The batched flow only collapses the consent step.
  • Detail in Cross-product / ZPIP.

Outbound to external processors

Per-event, what data leaves ZevID. Per-vendor detail in third-parties.md.

Trigger Data sent Destination Frequency
Signup, verify-OTP, password change, new-login alert, welcome email First name, email ZeptoMail Real-time, one-shot per event
Phone verification, SMS MFA E.164 phone number (without leading +), 6-digit code, brand message Termii Real-time, one-shot per event
Profile-picture upload Image bytes Cloudflare R2 bucket Real-time, on user action
Application exception Stack trace + request context (request scrubber strips body) Sentry-compatible ingest at Better Stack Real-time
Structured app logs Log lines Better Stack (Logtail) Continuous

All vendors above are confirmed wired in production (per third-parties.md). Sentry-compatible exception ingest and Logtail structured logs both point at Better Stack (the SENTRY_DSN env is the Sentry-compatible endpoint Better Stack provides; there is no separate Sentry vendor account).

Inbound from external sources

ZevID does not currently consume webhooks from external vendors. (Email + SMS providers report delivery via their own dashboards, not back into ZevID's DB.)

Internal data flows (within ZevID)

No internal replicas. ZevID runs against a single primary database. No analytics warehouse, no read replica with PII, no cross-region copy. Backups are handled by the database provider — see security.md.