Task 26: Claim Status Sync (originally: webhook registration)¶
Status: ✅ DONE 2026-07-16 — via CIH polling, not webhooks (see "Design revision" below; webhooks turned out not to exist in CIH v2)
Design revision (2026-07-16, verified against CIH's OpenAPI spec)¶
The webhook plan below was invalidated by CIH's actual API
(https://stagingcih.velocitycareerlabs.io/documentation/json):
- CIH v2 exposes no outbound webhook configuration anywhere — the
tenant create schema has no
webhookUrl(extra fields are silently ignored), the issuer-service schema has none, and the depotmessagingSettings.webhookUrlappears only on the read shape with no API to set it. The pre-existingwebhookUrlcode invelocity-registrar.service.tsand the/claim/webhook/vnfreceiver were written against a capability that does not exist. - Claim state is exposed for polling instead: each stored credential
(
GET /operator/credentials/get) carriesacceptedAt/rejectedAt, keyed bycredentialReference— which we set tocredential_offers.idat issuance.
What was built (working, verified end-to-end 2026-07-16)¶
ClaimSyncService (issuer/services/claim-sync.service.ts):
- Cron every 5 minutes (CLAIM_SYNC_ENABLED=false disables) + manual
POST /issuer/credentials/sync-claims
- Pulls the tenant's credentials from CIH, matches credentialReference →
local offer id, flips sent/created offers with acceptedAt to
status=claimed, claimed_at=acceptedAt, claim_method=vnf_app
- Live verification: offer 232b1629… flipped to claimed with the exact
wallet-acceptance timestamp (16:37:11Z) — no tunnel/public URL needed
- Uses the global staging tenantId like the rest of issuance —
per-org resolution is Task 27
Leftovers / cleanup opportunities¶
PUBLIC_API_URLwebhook registration code (velocity-tenant.service) and the/claim/webhook/vnfreceiver are now dead-ends for claims — harmless (CIH ignores the extra field) but can be removed or kept for a future CIH version that adds webhooks- Old offers issued under a deleted tenant can never flip — mark manually or expire them
Original (superseded) webhook plan — kept for history¶
Created: 2026-07-16 Priority: High — last functional loose end in the issuance → claim loop Effort: ~half a day code + deploy-time config
Problem (verified 2026-07-16 during local Beta testing)¶
A credential was claimed successfully in the Velocity staging web wallet
(shows Verified), but our credential_offers row stays at status=sent
with no claimed_at. Everything that reads offer status is therefore stale:
- Credentials dashboard status chips + "Credentials Claimed" stats
- Candidate view (
/candidates/[id]) claimed counters - Employee detail → Credential Offers section
- Reminder engine (GAP-06): keeps nudging candidates who already claimed
(offers stay
sent, which is exactly what the reminder cron looks for)
Root cause¶
The claim happens wallet ↔ Velocity CIH; our backend is not in that path. We only learn of it via a webhook, and:
- We never register a webhook URL with CIH. The receiving endpoint
already exists —
POST /api/v1/claim/webhook/vnf(claim.controller.ts→ClaimService.processVNFWebhook) — butvelocity-tenant.service.tsnever passeswebhookUrlwhen creating the tenant, so CIH has nowhere to send claim notifications. This affects every environment, not just local. - Local dev is unreachable anyway — Velocity's cloud cannot call
http://localhost:8000; a tunnel (ngrok etc.) is required for local end-to-end testing.
Implementation plan¶
- ✅ DONE 2026-07-16 —
PUBLIC_API_URL+ optionalVNF_WEBHOOK_BEARER_TOKENadded to.env.example. Unset → registration skipped with a warning log (previous behaviour). - ✅ DONE 2026-07-16 —
velocity-tenant.service.tsnow passeswebhookUrl: "${PUBLIC_API_URL}/api/v1/claim/webhook/vnf"(plus bearerwebhookAuthwhen the token is set) in the CIH create-tenant request. - Existing tenants: tenants created before this change have no webhook. Check whether CIH exposes a tenant-update call; if not, the org's tenant must be re-created (retry-tenant-creation path) or updated manually via the Velocity dashboard.
- Verify the payload contract:
processVNFWebhookwas written against the documented shape but has never received a real staging call. First live webhook should be inspected (it's logged in full) and the handler adjusted if the real payload differs. - Security: the webhook route is unauthenticated by design (CIH calls
it). Confirm/introduce a shared-secret or signature check
(
ClaimServicehas an HMAC helper — check whether CIH signs webhooks and validate if so).
How to test locally (today)¶
# 1. Expose the local backend
ngrok http 8000 # → https://<id>.ngrok.app
# 2. Set the env var and restart backend
echo 'PUBLIC_API_URL=https://<id>.ngrok.app' >> app/backend/.env.development
docker compose -f docker-compose.local.yml restart backend
# 3. Re-create the org's tenant (retry tenant creation) so CIH learns the URL
# 4. Issue a credential, claim it in the staging wallet
# 5. Watch the webhook land:
docker logs -f backend_rec_instance | grep -i webhook
# 6. Confirm: credential_offers.status = claimed, claimed_at set
Acceptance criteria¶
- Tenant creation registers the webhook URL when
PUBLIC_API_URLis set - Claiming a credential flips the offer to
claimed+ setsclaimed_atwithout manual intervention - Claimed offers stop receiving reminder emails
- Webhook payload authenticity is validated (or explicitly documented as not supported by CIH)
-
25-phase-a-pending-tasks.mdentry marked done
Cross-references¶
- 25-phase-a-pending-tasks.md — Phase A checklist (linked entry)
app/backend/src/issuer/controllers/claim.controller.ts— existing receiverapp/backend/src/admin/services/velocity-tenant.service.ts— where registration goesapp/backend/src/shared/services/cih/cih-api.types.ts—webhookUrlalready in the request type