Skip to content

feat(backend): add webhook delivery system for card view events#187

Open
Dipti45sktech wants to merge 1 commit into
Dev-Card:mainfrom
Dipti45sktech:Feat
Open

feat(backend): add webhook delivery system for card view events#187
Dipti45sktech wants to merge 1 commit into
Dev-Card:mainfrom
Dipti45sktech:Feat

Conversation

@Dipti45sktech
Copy link
Copy Markdown
Contributor

@Dipti45sktech Dipti45sktech commented May 19, 2026

Summary

Implements the webhook delivery system described in issue #40. Users can register external URLs to receive signed POST requests whenever their card or profile is viewed. The system handles payload signing with HMAC-SHA256, retries failed deliveries with exponential backoff, and logs every attempt for observability.

The contact.saved event is wired into the schema and validation but isn't dispatched yet since the contact-save feature doesn't exist in the codebase - left a TODO for when that gets built.

Closes #40


What Changed

  • prisma/schema.prisma - Added WebhookEndpoint and WebhookDelivery models with a relation back to User. Endpoints store an encrypted secret and a list of subscribed event types. Deliveries track status, response codes, attempt counts, and retry scheduling.
  • src/utils/webhookDispatch.ts (new) - Core dispatch logic. dispatchWebhook() finds matching endpoints for a user+event, creates delivery records, then fires off async HTTP POSTs. Each request is signed with X-DevCard-Signature: sha256=<hex> using HMAC-SHA256. Failed deliveries retry up to 3 times at 30s, 5min, and 30min intervals.
  • src/routes/webhooks.ts (new) - CRUD routes for managing webhook endpoints: register (max 5 per user), list, delete, view delivery logs (paginated), and rotate secret. Secrets are auto-generated, encrypted at rest, and only shown in plaintext once at creation/rotation.
  • src/app.ts - Registered the new webhook routes at /api/webhooks.
  • src/routes/public.ts - Hooked dispatchWebhook() into the two card/profile view handlers so card.viewed events fire after view tracking.
  • src/__tests__/webhooks.test.ts (new) - 17 tests covering endpoint registration, max limit enforcement, validation, listing, deletion, delivery logs pagination, secret rotation, HMAC signature correctness, and delivery success/failure/timeout scenarios.

How to Test

  1. Run pnpm install from the repo root
  2. Run pnpm test from apps/backend - all 25 tests should pass (17 new + 8 existing)
  3. To verify the schema, run npx prisma migrate dev from apps/backend (requires a running Postgres instance)
  4. To test manually, start the dev server and:
    • POST /api/webhooks with { "url": "https://your-endpoint.com", "events": ["card.viewed"] } (needs auth token)
    • Visit a public profile at GET /api/u/:username - your endpoint should receive a signed POST

Additional Context

  • The tsc build has errors but they're all pre-existing across the codebase (e.g. app.authenticate type augmentation missing from every route file, implicit any params in cards.ts, follow.ts, etc.). My new files only carry the same authenticate pattern - webhookDispatch.ts compiles clean.
  • Retries are handled with in-process setTimeout for now. For production at scale, this should probably move to a proper job queue (e.g. BullMQ backed by the existing Redis instance), but that felt out of scope for this PR.
  • The contact.saved event type is accepted in endpoint registration and validation, but nothing dispatches it yet since there's no contact-save feature. Added a TODO comment in public.ts so it's easy to wire up later.
  • Secrets are encrypted using the existing AES-256-GCM utility in encryption.ts - no new crypto dependencies.

@Dipti45sktech
Copy link
Copy Markdown
Contributor Author

Hi @ShantKhatri, could you please review my PR?

@Harxhit Harxhit added gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking. critical Includes schema, architecture, or other critical core functionality changes. labels May 19, 2026
@Harxhit
Copy link
Copy Markdown
Collaborator

Harxhit commented May 19, 2026

Hi @ShantKhatri, could you please review my PR?

Could you please add the test proofs in the PR description as well? Since this PR involves schema changes, I have marked it as critical, so the review may take some additional time.

secret String // encrypted via encryption.ts
events String[] // e.g. ["card.viewed", "contact.saved"]
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing updatedAt needed for secret rotation auditing

responseCode Int? @map("response_code")
attempts Int @default(0)
nextRetryAt DateTime? @map("next_retry_at")
createdAt DateTime @default(now()) @map("created_at")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing updatedAt can't tell when last event fired.

endpointId String @map("endpoint_id")
eventType String @map("event_type")
payload Json
status String @default("pending") // "pending" | "success" | "failed"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing errorMessage String no way to surface failure reason.

responseCode Int? @map("response_code")
attempts Int @default(0)
nextRetryAt DateTime? @map("next_retry_at")
createdAt DateTime @default(now()) @map("created_at")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing deliveredAt DateTime? no success timestamp.


model WebhookDelivery {
id String @id @default(uuid())
endpointId String @map("endpoint_id")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No indexing on endpointId delivery log query will be slow.

attempts Int @default(0)
nextRetryAt DateTime? @map("next_retry_at")
createdAt DateTime @default(now()) @map("created_at")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No indexing for status and nextTryAt worker will scan whole thing.

* Max 5 endpoints per user. Auto-generates and encrypts a secret.
* Returns the plaintext secret once — user must store it.
*/
app.post('/', async (request: FastifyRequest, reply: FastifyReply) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request route and Request schema seems is missing here.

}

// Enforce max 5 endpoints per user
const existingCount = await app.prisma.webhookEndpoint.count({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works,though count check alone may still allow race conditions

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import crypto from 'crypto';
import { z } from 'zod';
import { encrypt } from '../utils/encryption.js';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File is missing?

},
});

return reply.status(201).send({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typed response missing.

* Returns all webhook endpoints for the authenticated user.
* The secret field is never returned.
*/
app.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request route and Request schema seems is missing here.

app.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
const userId = (request.user as any).id;

const endpoints = await app.prisma.webhookEndpoint.findMany({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a limit here?

@Harxhit
Copy link
Copy Markdown
Collaborator

Harxhit commented May 19, 2026

Error handling around the business logic can be improved here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

critical Includes schema, architecture, or other critical core functionality changes. gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

backend: implement webhook delivery system for card view and contact-save events

2 participants