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.
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'ssecurity.md). - The OTP is generated by
OtpService.generateOtp(src/modules/auth/otp/otp.service.ts:23) with channelemail, purposelogin. - 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
purposefield (mfa_sms:<phoneId>). - Geo-fencing + IP-allowlist checks fire before successful login is granted; failures land in
login_eventsaslogin_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.phonecolumn 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
tierandmetadataare 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.
ZPIP cross-product consent (outbound consent screen → grant)¶
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.