Clockify marketplace add-on that stops running timers and locks projects the moment either a time estimate or a budget estimate is reached, and restores them when the cap condition clears.
Server-side enforcement: once a project hits its cap, contributors can't keep tracking time or cost against it until an admin raises the estimate, a reset window reopens, or earlier entries are edited down.
| Status | Private install validated on a Pro workspace. Hard-stop, restore, and webhook paths verified against live data. |
| Tests | 202 / 202 passing (./mvnw -B test); CI runs unit + Testcontainers jobs on ubuntu-latest. |
| Manifest | schema 1.3, PRO, 1 admin sidebar, 4 lifecycle, 5 webhooks, 8 scopes, 2 settings |
| Hard-stop mechanism | Webhook (instant) + scheduler tick (≤60s backstop). See How enforcement works. |
| Audit trail | Every lock / unlock / cutoff / timer-stop persisted in guard_events, exposed via GET /api/guard/events |
| Data store | PostgreSQL (not file-backed). Installation tokens + webhook tokens AES-256 at rest. |
| Webhook delivery | Confirmed working on production Pro workspaces (verified 2026-04-19). The 60s scheduler tick remains the durable backstop. |
Two independent paths notice timer activity. Either one triggers the same guard engine, so the addon is correct even if one path is temporarily unavailable.
┌─────────────────────────────┐ ┌─────────────────────────────────────┐
│ Webhook (Clockify → addon) │ │ Scheduler (addon → Clockify) │
│ Near-instant reaction │ │ 60s tick, polling Reports API │
│ /webhook/new-timer-started │ │ Everything in scheduler:tick / │
│ /webhook/timer-stopped │ │ scheduler:due-job source tag │
│ /webhook/new-time-entry │ └───────────────┬─────────────────────┘
│ /webhook/time-entry-updated│ │
│ /webhook/time-entry-deleted│ │
└──────────────┬──────────────┘ │
│ │
▼ ▼
┌────────────────────────────────────────────┐
│ EstimateGuardService.reconcileProject() │
│ ├── load usage from Reports API │
│ ├── load running timers │
│ ├── assess vs ProjectCaps │
│ ├── exceeded? → stopRunningTimer │
│ │ lockProject │
│ ├── lockNow? → same │
│ └── cutoff? → syncCutoffJobs │
│ │
│ Every outcome writes guard_events row │
│ (LOCKED / UNLOCKED / CUTOFF_SCHEDULED / │
│ TIMER_STOPPED) with reason + source │
└────────────────────────────────────────────┘
| Scenario | Behavior |
|---|---|
| Running timer crosses the cap | Timer end is set at the exact cap boundary (cutoffAt.toString()), not where the scheduler happened to notice. |
| Manual entry pushes the project over the cap | The entry itself lands (addon can't retro-reject it — it's already posted), but the project locks immediately after. Worst case = cap + one user's one manual entry. |
| Project already over cap | Subsequent timer starts are stopped on the next tick; new manual entries are blocked at the Clockify API layer because membership has been pruned. |
| Cap crosses back under (admin raises the estimate, reset window) | Next reconcile unlocks, restores memberships from project_lock_snapshots, writes UNLOCKED event. |
| Budget cap basis | Billable amount = hourlyRate × billable duration (per-user hourlyRate first, project defaultHourlyRate fallback) plus expenses when includeExpenses is set. Non-billable entries do not accrue against the budget cap (they still accrue against the time cap if one exists). Internal cost (costRate) is not used. |
- Schema
1.3 - 1 admin-only sidebar at
/sidebar(displays current guard state and recent events) - 4 lifecycle routes:
installed,deleted,status-changed,settings-updated - 5 manifest-declared webhooks (
NEW_TIMER_STARTED,TIMER_STOPPED,NEW_TIME_ENTRY,TIME_ENTRY_UPDATED,TIME_ENTRY_DELETED).PROJECT_UPDATEDand the fourEXPENSE_*events are not declarable under schema 1.3; their cap-state changes are picked up by the 60s reconcile scheduler. See SPEC.md §1 for the full rationale and the hibernated controller plumbing. - 8 scopes:
TIME_ENTRY_READ/WRITE,PROJECT_READ/WRITE,USER_READ,EXPENSE_READ,REPORTS_READ,WORKSPACE_READ(workspace timezone lookup) - 2 admin-only settings:
enabled(checkbox, defaulttrue),defaultResetCadence(dropdown:NONE/WEEKLY/MONTHLY/YEARLY) minimalSubscriptionPlan: "PRO"- Hard-stop behavior only — no observe-only mode
- Java 21, Spring Boot 3.3, Maven
- Clockify
com.cake.clockify:addon-sdk:1.5.3 - PostgreSQL 16, Flyway, Spring Data JPA
- Thymeleaf sidebar shell + vanilla JavaScript
- Docker + docker-compose for parity
| Area | Contents |
|---|---|
src/main/java |
Spring Boot service: manifest, lifecycle, webhooks, guard engine, lock service, scheduler, protected APIs, async install retrier |
src/main/resources |
application.yml, sidebar Thymeleaf + JS, Flyway migrations, Clockify RS256 public key |
src/test |
Unit tests for guard engine + @SpringBootTest coverage for manifest, auth, lifecycle, webhooks, protected APIs, async backoff |
Dockerfile, docker-compose.yml, .env.example |
Local + production build and run |
SPEC.md, ARCHITECTURE.md |
Locked product + technical contract |
- JWT verification — RS256 against the published Clockify public key, with hard checks on
iss,type,sub, numericexp, and when presentnbf/iat. - Tokens at rest — installation tokens and per-workspace webhook tokens are AES-256 encrypted via
spring-security-cryptoEncryptors.delux(AES-256-CBC, random 16-byte IV per value — non-deterministic).addon.encryption-key-hexandaddon.encryption-salt-hexhave no defaults; the app fails fast at startup if either is unset or matches the well-known checked-in example values in.env.example. - Per-route webhook tokens — a token registered for one webhook path cannot authenticate a different webhook path. Constant-time compare.
- Protected APIs — every
/api/*call requiresX-Addon-Token.auth_tokenis stripped from the iframe URL before any backend call. - Local assets only — the sidebar serves all CSS/JS from this origin; no third-party CDN dependencies.
- URL allow-list —
ClockifyUrlNormalizerenforces HTTPS and*.clockify.mehost; a forged JWT cannot redirect outbound calls. - Deduplication — inbound webhooks are short-circuited on replay by
(event_id, signature_hash)uniqueness inwebhook_events. Rows are purged hourly past a 24h retention window by a ShedLock-guarded scheduler. - Bounded outbound calls — every Clockify call is subject to 5s connect / 10s read timeouts and one
Retry-After-honoring retry on HTTP 429, so a stalled or rate-limiting Clockify cannot pin webhook threads.
Summary:
docker compose up -d postgres
cp .env.example .env
# Generate real encryption material into .env
openssl rand -hex 32 # APP_ENCRYPTION_KEY_HEX (>= 64 hex chars / 256 bits)
openssl rand -hex 32 # APP_ENCRYPTION_SALT_HEX (>= 64 hex chars / 256 bits)
export $(grep -v '^#' .env | xargs)
./mvnw -B test # 202/202 green
./mvnw -B spring-boot:run # starts on :8080Expose http://localhost:8080 over HTTPS (ngrok, cloudflared) and install privately in a Clockify Pro workspace for smoke validation. The manifest is at GET {public-url}/manifest.
curl -s http://localhost:8080/manifest | jq '{schema:.schemaVersion, webhooks:(.webhooks|length), scopes:(.scopes|length), plan:.minimalSubscriptionPlan}'
# → {"schema":"1.3","webhooks":5,"scopes":8,"plan":"PRO"}
curl -s http://localhost:8080/actuator/health
# → {"status":"UP"}