Skip to main content

Certified Implementation Partner

Invite-only certification programme for partner companies: daily tests + proof steps, reviewed by super-admins, paying USD wallet rewards and ending in a PDF certificate, a public directory listing, partner invites, and affiliate sign-up links. Backend plus a frontend surface at /partner. Originally self-serve (877dced3f); made invite-only with cohorts, applications, our-own-GHL stage tags, and the directory in 2026-08/09 (c5b2d7ebb, fbc819d53, 10cce3c8e and follow-ups).

Gating

  • company.partner_certification_enabled (Boolean, default false, CompanyModel). Set when a partner invite code is redeemed at signup (AuthController; the same write forces lead_intelligence/Snitcher off — invited partners have no package) or by super-admin toggle PUT /company/:_id/partner-certification. The old reusable ?pkg=<certification package> sign-up link is refused when block_package_signup is on (default): "The partner programme is invite-only."
  • isPartnerAccount({company, email, package_id, invited}) (certificationSettings.js) is the single "is this a partner" answer used at registration, subscribe, both cancels, reactivate, and admin bulk sync — a customer lifecycle tag on a partner would fire client automations and erase the partner trigger tag.
  • Partner accounts: no 500-credit monthly grant and ManualAiCreditsJob skips them (f00d66ae3); exempt from the free-session paywall only while they have never had a subscription (CompanyController ~4315, 28c941f54); hidden from admin company/user lists by default (c09d72f12); tagged as applicants, never customers, in our own GHL (96e1a61cf).
  • Re-checked server-side on every student endpoint: CertificationController._gate() requires user_type === 'web_owner', an owned company, and the flag — flipping the flag off closes the API, not just the UI.
  • Frontend route /partner (views/partner/Index.vue) carries meta webOwnerOnly: true but not the feature flag — gating is server-side.

Invites, cohorts, applications (2026-08)

  • CertificationCohortModel (certification_cohorts): number (unique), name, seats_total, status: open | closed (default open), starts_on/ends_on (display only). Several cohorts may be open at once (partnerInvites.openCohorts; openCohort = newest open with a seat left). Closing a cohort kills its unredeemed codes — the kill switch for a leaked code.
  • PartnerInviteCodeModel (partner_invite_codes): code KK-XXXX-XXXX from an alphabet without O/0/I/1/L, cohort_id, issued_by_company_id / user_id (null = platform-issued), invited_email (lowercase, matched inside the redemption filter — a forwarded bound code is refused), status: unused | redeemed | revoked, expires_at. No expired status — expiry is computed at read. Redemption is a conditional update on status, so a shared code admits exactly one. A code taken mid-signup still creates the account and tells the team.
  • CertificationApplicantModel (certification_applicants): unique (email, cohort_id); status: pending | accepted | rejected | waitlisted, invite_code, sponsored (skipped the queue with a partner's code), ghl_pushed_at, company_id (sparse — set only when the answers were captured in-app by the onboarding PartnerStep). An application is not an account.
  • PartnerCertificationModel gained listing.* (directory: slug frozen once published, visible + consent_at both required, unpublished_by_admin moderation-by-exception), agency_links_enabled (null = follow programme), agency_invite_id, invite_quota_override (null0partnerQuota uses != null), steps[].internal_done_at.
  • Public/marketing surfaces sit behind partnerApiKey (PARTNER_API_KEY, in both backend configmaps) + directoryRateLimiter: GET /certification/open-cohort, POST /certification/apply (PartnerApplicationController), GET /certification/invite/:code, GET /public/partners[/:slug]. The code check is server-side only so live codes can't be hunted. Portal/invite/reviewer links are built from config, never the request origin (b555fb1e1, 8d7b69f39, 643b6f008).
  • Partners can invite: default partner_invite_quota 5, per-partner override, uncertified students get none; issuing emails the invite; codes expire after invite_expiry_days (30; 0 = never).

Stage tags in our own GHL (PlatformGhl)

Every partner contact carries exactly one stage tag at a time: contactTags() writes the reached stage and staleTags() removes every other stage tag plus renamed/disabled ones (certificationSettings.STAGE_TAGS, with joined between accepted and enrolled — an account existing means joined; accepted is a queue decision not yet acted on, certificationTagBackfill.stageFor), plus an optional per-cohort tag and the invite code in the custom field named by invite_code_ghl_field ("Certification Invite Code" — create it in GHL at go-live). Push path partnerApplicants.pushApplicantToGhlPlatformCrmService.upsertContact with merge_tags: true, remove_tags, custom_fields: [{name}]; PlatformCrmService.customFieldId resolves by display name, full key (contact.invite_code), or bare key, and an unknown field is skipped, never fatal. Gotcha: tags on a GHL update replaces the set — merge is computed client-side, and GoHighLevelService.createPrivateContact deliberately keeps replacement as the default because customer lifecycle tags must supersede (trial → active). A blank tag setting pushes untagged — never invent a trigger. Backfill: POST /admin/certification/ghl-tag-backfill (202 + job_id, dry-run unless apply: true, optional cohort_id) + jobs poll; diagnostic GET /admin/certification/ghl-tag-check?email= names the three reasons a contact ends up untagged.

Student API — CertificationController (certificationRoutes.js, passport)

RouteDoes
GET /certification/meprogress; creates the cert doc as invited on first read
POST /certification/enrollopt-in; unlocks step 1; pays enrollment_reward_usd
POST /certification/dismisshides the invite (only while invited)
GET /certification/steps/:step_id/testshuffled questions, answer key stripped
POST /certification/steps/:step_id/attemptserver-side grading; every attempt recorded (unlimited retakes)
POST /certification/steps/:step_id/submitsubmit/resubmit a manually-reviewed step

Steps are addressed by subdoc _id everywhere; number is display order only. Grading compares selected option _ids so both questions and options can shuffle without leaking the key; responses only ever expose wrong_question_ids.

Admin API — AdminCertificationController (adminCertificationRoutes.js, passport + _ensureAdmin)

RouteDoes
GET /admin/certification/queueall submitted steps, oldest first
POST /admin/certification/:id/steps/:step_id/reviewdecision: approve|reject + optional reason
GET /admin/certification/students, .../:idlists companies with the flag on (never-loaded = never_seen); detail incl. attempts + wallet
POST /admin/certification/students/:id/creditsmanual wallet correction, delta_usd (signed) + reason
GET/POST /admin/certification/tests, PUT/DELETE .../:dayauthor per-day questions; delete renumbers and only affects new enrolments
GET/PUT /admin/certification/settingsthe certification settings row

Admin UI: /admin/certification/{queue,students,tests,settings} — see Admin overview.

Models

ModelCollectionNotes
PartnerCertificationModelpartner_certificationsone per company (unique company_id); enrollment_status: invited|dismissed|enrolled; steps[] subdocs (kind: test|video|social|badge, status: locked|in_progress|submitted|approved|rejected); certificate_name; certificate.{certificate_id, issued_at, file_id}
CertificationAttemptModelcertification_attemptsevery attempt stored (pass or fail — abuse visibility)
CertificationTestModelcertification_testsone row per day (unique); question type: multiple_choice|link|file, per-question max_file_mb/accepted_types overrides

Step sequence: one test step per day, then video, social (4 posts: 3× facebook + 1× linkedin), badge (Upwork + Fiverr). While invited the step list rebuilds from current days on every read; once enrolled it is frozen. All transitions route through app/lib/certification.js (the choke point — gating, emails, credit awards can't diverge per caller); every status change emails the student (mail failure never rolls back a transition).

Settings row certification

app/lib/certificationSettings.js — single {key: 'certification'} row in the shared settings collection, spread over defaults:

FieldDefault
package_idnull (legacy; block_package_signup: true refuses the package link)
partner_invite_quota / invite_expiry_days / invites_enabled5 / 30 / true
applications_enabled / applications_close_on / applicant_ghl_tagtrue / null / certification-applicant
stage_ghl_tags / cohort_ghl_tag_enabled / invite_code_ghl_fieldSTAGE_TAGS / true / Certification Invite Code
agency_links_enabled / agency_invite_id / reviewer_emailstrue / null / ''
enrollment_reward_usd5 (0 disables)
step6_reward_usd25 (keyed to kind: social, not step number)
step7_reward_usd25 (kind: badge)
max_file_mb25
accepted_file_typespng, jpg, jpeg, pdf, mp4, mov

Wallet rewards (dollars)

There is no wallet service — the wallet is subscription.remaining_purchased_ai_credits at 100 credits = $1 (CREDITS_PER_USD in app/lib/certification.js; ed06addbb fixed rewards being credited at 1/100 of the intended amount). adjustCredits(): ledger row in ai_credit_logs created first to reserve the idempotency key (cert:<cert_id>:enrollment|social|badge — kind-keyed, survives renumbering; partial unique index), then $inc, floor-clamped at zero. Duplicate approve = no-op. Enrolment reward pays after cert.save() so a wallet failure can't half-enrol. Admin surfaces are dollars end-to-end (delta_usd, wallet_usd).

File-upload proof steps

No dedicated upload endpoint — the shared POST /files with path: 'certification' (UploadHelper, S3 or local). Enforcement that counts is server-side in CertificationController._checkUploads() (re-fetches each file_id, checks size vs max_file_mb and extension vs allowed types) — browser checks are a courtesy since POST /files is generic. Proof steps (social/badge) accept images only (fixed IMAGE_TYPES); test-step uploads are validated before the attempt is recorded.

Certificate — CertificateService

Renders text against an SVG/PNG artwork with bundled NotoSans fonts, the certificate name auto-shrunk to the ruled line, and a "Class of <year>" seal generated from the issue date (30720033c, 7013b147f).

app/services/CertificateService.js: PDF via pdfkit (A4 landscape), drawing over public/assets/certification/certificate-template.png (falls back to a plain layout if missing); field positions in one LAYOUT constant. ID format KKCIP-<year>-<last6 of cert id>. issue() is idempotent (certificate.file_id early-return); the file goes through the normal UploadHelper path and is attached to the congratulation email (certificate.html — the HTML here is the email template, not the certificate). Deploy note: package-lock.json is gitignored in backend — deploys need npm install for pdfkit.