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, defaultfalse,CompanyModel). Set when a partner invite code is redeemed at signup (AuthController; the same write forceslead_intelligence/Snitcher off — invited partners have no package) or by super-admin togglePUT /company/:_id/partner-certification. The old reusable?pkg=<certification package>sign-up link is refused whenblock_package_signupis 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
ManualAiCreditsJobskips 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()requiresuser_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 metawebOwnerOnly: truebut 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):codeKK-XXXX-XXXXfrom an alphabet withoutO/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. Noexpiredstatus — expiry is computed at read. Redemption is a conditional update onstatus, 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 onboardingPartnerStep). An application is not an account.PartnerCertificationModelgainedlisting.*(directory: slug frozen once published,visible+consent_atboth required,unpublished_by_adminmoderation-by-exception),agency_links_enabled(null = follow programme),agency_invite_id,invite_quota_override(null≠0—partnerQuotauses!= 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_quota5, per-partner override, uncertified students get none; issuing emails the invite; codes expire afterinvite_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.pushApplicantToGhl →
PlatformCrmService.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)
| Route | Does |
|---|---|
GET /certification/me | progress; creates the cert doc as invited on first read |
POST /certification/enroll | opt-in; unlocks step 1; pays enrollment_reward_usd |
POST /certification/dismiss | hides the invite (only while invited) |
GET /certification/steps/:step_id/test | shuffled questions, answer key stripped |
POST /certification/steps/:step_id/attempt | server-side grading; every attempt recorded (unlimited retakes) |
POST /certification/steps/:step_id/submit | submit/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)
| Route | Does |
|---|---|
GET /admin/certification/queue | all submitted steps, oldest first |
POST /admin/certification/:id/steps/:step_id/review | decision: approve|reject + optional reason |
GET /admin/certification/students, .../:id | lists companies with the flag on (never-loaded = never_seen); detail incl. attempts + wallet |
POST /admin/certification/students/:id/credits | manual wallet correction, delta_usd (signed) + reason |
GET/POST /admin/certification/tests, PUT/DELETE .../:day | author per-day questions; delete renumbers and only affects new enrolments |
GET/PUT /admin/certification/settings | the certification settings row |
Admin UI: /admin/certification/{queue,students,tests,settings} — see
Admin overview.
Models
| Model | Collection | Notes |
|---|---|---|
PartnerCertificationModel | partner_certifications | one 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} |
CertificationAttemptModel | certification_attempts | every attempt stored (pass or fail — abuse visibility) |
CertificationTestModel | certification_tests | one 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:
| Field | Default |
|---|---|
package_id | null (legacy; block_package_signup: true refuses the package link) |
partner_invite_quota / invite_expiry_days / invites_enabled | 5 / 30 / true |
applications_enabled / applications_close_on / applicant_ghl_tag | true / null / certification-applicant |
stage_ghl_tags / cohort_ghl_tag_enabled / invite_code_ghl_field | STAGE_TAGS / true / Certification Invite Code |
agency_links_enabled / agency_invite_id / reviewer_emails | true / null / '' |
enrollment_reward_usd | 5 (0 disables) |
step6_reward_usd | 25 (keyed to kind: social, not step number) |
step7_reward_usd | 25 (kind: badge) |
max_file_mb | 25 |
accepted_file_types | png, 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.