Skip to content

Webhooks

Webhooks push feed events to your backend as they happen: register an HTTPS endpoint once and every activity, reaction, and follow change on your tenant is delivered to it. Delivery runs on Hookdeck Outpost as isolated infrastructure — it owns destination storage, HMAC-SHA256 signing, retries with backoff, and the delivery dashboard, so a slow or failing consumer never touches the feed API’s write path.

One sentence of positioning: webhooks are the server-to-server sync story (mirror feed events into your own database, trigger downstream jobs), while live-mode head polling is the client liveness story (live: true in the React hooks polls a cheap change signal to refresh a rendered feed) — use both, for different jobs.

Webhook management is server-token only — it lives in @dropinnodex/server, never in the browser SDKs. A user-token call to any webhook route returns FORBIDDEN.

import { DropInServer } from '@dropinnodex/server'
const server = new DropInServer({
tenantId: 'acme',
apiKey: process.env.DROPIN_API_KEY!,
apiSecret: process.env.DROPIN_API_SECRET!,
})
// Register — the response includes the destination id and the signing secret.
const dest = await server.webhooks.create({ url: 'https://acme.com/dropin-hooks' })
console.log(dest.id) // pass this to webhooks.remove() later
console.log(dest.credentials) // the HMAC signing secret — store it NOW, in your secret store
// Round out the surface:
const all = await server.webhooks.list()
await server.webhooks.remove(dest.id)

The create response is the moment to capture the signing secret — persist it in your secret store right away and verify every delivery with it. A destination subscribes to all event topics (topics: ["*"]); filter by type in your handler if you only care about some events.

Six event types fire, each only when a write actually changed a row — a no-op (double-react, duplicate follow, re-POST of the same (foreign_id, time) activity) emits nothing:

Event Payload (data)
activity.added the full activity (same shape the API returns)
activity.removed { id }
reaction.added { id, activity_id, user_id, kind, custom, created_at }
reaction.removed { id, activity_id, kind, user_id }
follow.added { source, target }
follow.removed { source, target }

An example activity.added payload:

{
"id": "b3f0c1d2-…",
"actor": "user:maya",
"verb": "workout",
"object": "workout:1234",
"target": null,
"foreign_id": "maya-w0",
"time": "2026-08-01T09:30:00.000Z",
"custom": { "sport": "run", "durationMin": 45 },
"origin_feed": "user:maya",
"reaction_counts": {}
}

Every event carries a deterministic idempotency id of the form <type>:<entity id> — e.g. reaction.added:c81a… — so a retried delivery of the same event always carries the same id.

Delivery semantics — what your consumer must do

Section titled “Delivery semantics — what your consumer must do”

Delivery is at-least-once: internal retries and Outpost’s own retry-with-backoff mean the same event can arrive more than once. Write your handler accordingly:

  1. Verify the signature on every request (below). Reject anything that fails.
  2. Dedupe on the event id. Same id = same event; process it once and treat repeats as already-handled.
  3. Return 2xx fast. Acknowledge immediately and do real work async — a slow response is what triggers redelivery in the first place. A non-2xx (or a timeout) is retried with backoff.

Ordering is not guaranteed across events — use the payload’s timestamps, not arrival order. And delivery is deliberately best-effort from the feed’s side: a webhook can never block, slow, or fail the domain write that caused it, so treat webhooks as a near-real-time signal, not a transactional log. If you need a guaranteed full mirror, periodically reconcile by reading feeds.

The other direction — your triggers writing into the feed

Section titled “The other direction — your triggers writing into the feed”

If you mirror your own events into the feed (a database trigger, a queue consumer, a Cloud Function), that producer is almost certainly at-least-once too. You do not need to build a dedupe layer for it. Every write is idempotent server-side, and the notification and webhook are gated on a row actually landing — not on the request arriving:

Redelivered write What happens the second time
Follow (userFollow) ON CONFLICT DO NOTHING — no duplicate edge, no second notification, no second follow.added
Activity with foreign_id + time the original activity is returned unchanged, no second fan-out
Activity without foreign_id a genuine duplicate — dedupe is opt-in, so pin a foreign_id on anything retryable
User upsert an upsert; running it twice is a no-op

So a trigger that fires twice produces one edge and one notification. The one case that does duplicate is an activity posted without foreign_id — see Idempotency & foreign_id, which also covers why foreign_id requires time and why re-posting a deleted identity returns 409.

Deliveries are HMAC-SHA256 signed with the secret returned by webhooks.create. Outpost documents the exact header names and signed-payload format for webhook destinations — the shape is the standard scheme: compute the HMAC of the raw request body (plus the timestamp header, if present) with your stored secret, and compare it to the signature header in constant time.

// Express-style sketch — check Outpost's docs for the exact header names.
import { createHmac, timingSafeEqual } from 'node:crypto'
app.post('/dropin-hooks', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac('sha256', process.env.DROPIN_WEBHOOK_SECRET!)
.update(req.body) // the RAW body — never a re-serialized parse
.digest()
const given = Buffer.from(String(req.headers['<signature header>']), 'base64')
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
// 1. dedupe on the event id 2. ack fast 3. process async
res.status(200).end()
})

Two rules that hold regardless of header names: sign-check the raw bytes (any JSON re-serialization breaks the HMAC), and compare with timingSafeEqual, never ===.

Batch imports are quiet by design. None of the /v1/batch/* routes emit webhooks (or notifications, or live-mode pings) — a cold-start import must not fire thousands of deliveries at your own backend for data it already has. See Migrating existing data.

  • Notifications — the in-app equivalent of these events, read through the SDKs instead of delivered to your backend.
  • Activities — the idempotency rules your producers rely on.
  • Migrating existing data — the quiet import path.
  • Limits — rate limits your webhook-triggered writes charge.